Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
+14
-8
@@ -12,14 +12,16 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
# rich and prompt_toolkit are imported lazily (inside the functions that use
|
||||
# them) rather than at module level. Importing this module is on the TUI
|
||||
# gateway's critical startup path purely to reach the lightweight update-check
|
||||
# helpers (``prefetch_update_check``); pulling rich.console + prompt_toolkit
|
||||
# eagerly added ~50ms of wasted imports before ``gateway.ready`` could fire.
|
||||
# Keep the type-only reference available to checkers without the runtime cost.
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +38,8 @@ _RST = "\033[0m"
|
||||
|
||||
def cprint(text: str):
|
||||
"""Print ANSI-colored text through prompt_toolkit's renderer."""
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
_pt_print(_PT_ANSI(text))
|
||||
|
||||
|
||||
@@ -471,7 +475,7 @@ def _display_toolset_name(toolset_name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
tools: List[dict] = None,
|
||||
enabled_toolsets: List[str] = None,
|
||||
session_id: str = None,
|
||||
@@ -490,6 +494,8 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
context_length: Model's context window size in tokens.
|
||||
"""
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
if get_toolset_for_tool is None:
|
||||
from model_tools import get_toolset_for_tool
|
||||
|
||||
|
||||
+59
-8
@@ -286,9 +286,22 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
Resolution order:
|
||||
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
|
||||
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||||
3. Container detection (/.dockerenv, /run/.containerenv, cgroup)
|
||||
4. .git directory presence -> 'git'
|
||||
5. Fallback -> 'pip'
|
||||
3. .git directory presence -> 'git'
|
||||
4. Fallback -> 'pip'
|
||||
|
||||
Note: running inside a container is NOT treated as "docker" on its own.
|
||||
The two supported install paths both self-identify via the
|
||||
``.install_method`` stamp (caught by step 1), so neither relies on
|
||||
container detection here:
|
||||
- the curl installer (scripts/install.sh, the README/website install
|
||||
command) git-clones the repo and stamps ``git``;
|
||||
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
|
||||
at boot via ``docker/stage2-hook.sh``.
|
||||
An unsupported manual install dropped into a container (no stamp) was
|
||||
wrongly classified as the published image by bare container detection,
|
||||
so ``hermes update`` bailed with "doesn't apply inside the Docker
|
||||
container". Without that fallback such installs fall through to the
|
||||
``.git``/pip checks and behave like any off-path install. See issue #34397.
|
||||
"""
|
||||
stamp = get_hermes_home() / ".install_method"
|
||||
try:
|
||||
@@ -300,9 +313,6 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
managed = get_managed_system()
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
from hermes_constants import is_container
|
||||
if is_container():
|
||||
return "docker"
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
if (project_root / ".git").is_dir():
|
||||
@@ -320,6 +330,34 @@ def stamp_install_method(method: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def is_uv_tool_install() -> bool:
|
||||
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
|
||||
|
||||
``uv tool install hermes-agent`` places the install at
|
||||
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
|
||||
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
|
||||
``uv pip install`` fails with ``No virtual environment found`` and the
|
||||
update path must use ``uv tool upgrade`` instead.
|
||||
|
||||
Detection is intentionally restricted to properties of the running
|
||||
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
|
||||
NOT consult ``uv tool list``: it would also return True when
|
||||
``hermes-agent`` happens to be uv-tool-installed on the machine while
|
||||
the *active* Hermes is a regular pip/venv install, causing
|
||||
``hermes update`` to upgrade the wrong copy. It would also block on a
|
||||
subprocess call (~seconds) just to compute a recommendation string.
|
||||
"""
|
||||
def _has_uv_tool_marker(path: str) -> bool:
|
||||
norm = os.path.normpath(path).replace(os.sep, "/").lower()
|
||||
return "/uv/tools/hermes-agent/" in norm + "/"
|
||||
|
||||
if _has_uv_tool_marker(sys.prefix):
|
||||
return True
|
||||
if _has_uv_tool_marker(sys.executable or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def recommended_update_command_for_method(method: str) -> str:
|
||||
"""Return the update command or guidance for a given install method."""
|
||||
if method == "nixos":
|
||||
@@ -329,9 +367,10 @@ def recommended_update_command_for_method(method: str) -> str:
|
||||
if method == "docker":
|
||||
return "docker pull nousresearch/hermes-agent:latest"
|
||||
if method == "pip":
|
||||
if is_uv_tool_install():
|
||||
return "uv tool upgrade hermes-agent"
|
||||
import shutil
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
if shutil.which("uv"):
|
||||
return "uv pip install --upgrade hermes-agent"
|
||||
return "pip install --upgrade hermes-agent"
|
||||
return "hermes update"
|
||||
@@ -1184,6 +1223,11 @@ DEFAULT_CONFIG = {
|
||||
# Mirrors `hermes -c` muscle memory. Default off so existing
|
||||
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
|
||||
"tui_auto_resume_recent": False,
|
||||
# When true (default), `hermes --tui` drops a one-time hint
|
||||
# ("subagents working · /agents to watch live") the first time a turn
|
||||
# starts delegating, nudging the user toward the live spawn-tree
|
||||
# dashboard. Set false to suppress the hint.
|
||||
"tui_agents_nudge": True,
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
@@ -1203,6 +1247,13 @@ DEFAULT_CONFIG = {
|
||||
# class of over-claim that otherwise forces users to run
|
||||
# `git status` to verify edits landed. Set false to suppress.
|
||||
"file_mutation_verifier": True,
|
||||
# Turn-completion explainer. When true (default), the agent appends a
|
||||
# one-line explanation to its final response whenever a turn ends
|
||||
# abnormally with no usable reply — empty content after retries, a
|
||||
# partial/truncated stream, a still-pending tool result, or an
|
||||
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
|
||||
# failure isn't silent from the UI's perspective. Set false to suppress.
|
||||
"turn_completion_explainer": True,
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
|
||||
@@ -204,6 +204,60 @@ def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None
|
||||
issues.append(fix)
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read the ``version = "..."`` from ``pyproject.toml`` at the project root.
|
||||
|
||||
Returns None when running from an installed wheel (no pyproject.toml ships
|
||||
with the package) or when the file can't be parsed. Reads only the
|
||||
``[project]`` version, ignoring any version strings that appear in other
|
||||
tables.
|
||||
"""
|
||||
pyproject = PROJECT_ROOT / "pyproject.toml"
|
||||
try:
|
||||
text = pyproject.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
in_project = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
in_project = line == "[project]"
|
||||
continue
|
||||
if in_project and line.startswith("version") and "=" in line:
|
||||
value = line.split("=", 1)[1]
|
||||
value = value.split("#", 1)[0].strip().strip("\"'")
|
||||
return value or None
|
||||
return None
|
||||
|
||||
|
||||
def _check_version_consistency(issues: list[str]) -> None:
|
||||
"""Verify pyproject.toml version matches hermes_cli.__version__.
|
||||
|
||||
A git conflict resolution (reset/merge) can revert one file without the
|
||||
other, leaving ``hermes --version`` reporting a stale version while
|
||||
``pyproject.toml`` is current. Detect that drift so users can re-sync.
|
||||
Silent no-op for installed wheels where pyproject.toml isn't present.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import __version__ as init_version
|
||||
except Exception:
|
||||
return
|
||||
pyproject_version = _read_pyproject_version()
|
||||
if pyproject_version is None:
|
||||
# Installed wheel or unreadable pyproject — nothing to cross-check.
|
||||
return
|
||||
if pyproject_version == init_version:
|
||||
check_ok("Version files consistent", f"({init_version})")
|
||||
else:
|
||||
_fail_and_issue(
|
||||
"Version mismatch between source files",
|
||||
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
|
||||
"Re-sync version files (e.g. run 'hermes update', or set "
|
||||
"hermes_cli/__init__.py __version__ to match pyproject.toml)",
|
||||
issues,
|
||||
)
|
||||
|
||||
|
||||
def _check_s6_supervision(issues: list[str]) -> None:
|
||||
"""Inside a container under our s6 /init, surface what s6 sees.
|
||||
|
||||
@@ -509,6 +563,10 @@ def run_doctor(args):
|
||||
check_ok("Virtual environment active")
|
||||
else:
|
||||
check_warn("Not in virtual environment", "(recommended)")
|
||||
|
||||
# Detect drift between pyproject.toml and hermes_cli/__init__.py versions
|
||||
# (a git conflict resolution can silently revert one but not the other).
|
||||
_check_version_consistency(issues)
|
||||
|
||||
_section("Required Packages")
|
||||
required_packages = [
|
||||
|
||||
+380
-14
@@ -396,6 +396,41 @@ def workspaces_root(board: Optional[str] = None) -> Path:
|
||||
return board_dir(slug) / "workspaces"
|
||||
|
||||
|
||||
def attachments_root(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which task file attachments are stored.
|
||||
|
||||
Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored
|
||||
per-board so attachments don't leak between projects. Each task gets
|
||||
its own ``<root>/.../attachments/<task_id>/`` subdirectory.
|
||||
|
||||
``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest
|
||||
precedence) for tests and unusual deployments.
|
||||
|
||||
``default`` uses ``<root>/kanban/attachments/``; other boards use
|
||||
``<root>/kanban/boards/<slug>/attachments/``.
|
||||
|
||||
Workers (which run with full file-tool access) read attached files
|
||||
by the absolute path surfaced in :func:`build_worker_context`. On the
|
||||
local terminal backend — the default for kanban — that path resolves
|
||||
directly. Remote backends (Docker/Modal) need this directory mounted;
|
||||
see the kanban docs.
|
||||
"""
|
||||
override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
slug = _normalize_board_slug(board)
|
||||
if slug is None:
|
||||
slug = get_current_board()
|
||||
if slug == DEFAULT_BOARD:
|
||||
return kanban_home() / "kanban" / "attachments"
|
||||
return board_dir(slug) / "attachments"
|
||||
|
||||
|
||||
def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path:
|
||||
"""Return the per-task attachment directory ``<root>/<task_id>/``."""
|
||||
return attachments_root(board=board) / task_id
|
||||
|
||||
|
||||
def worker_logs_dir(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which per-task worker logs are written.
|
||||
|
||||
@@ -831,6 +866,20 @@ class Comment:
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attachment:
|
||||
"""In-memory view of a row from the ``task_attachments`` table."""
|
||||
|
||||
id: int
|
||||
task_id: str
|
||||
filename: str
|
||||
stored_path: str
|
||||
content_type: Optional[str]
|
||||
size: int
|
||||
uploaded_by: Optional[str]
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
id: int
|
||||
@@ -957,6 +1006,23 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Files attached to a task (PDFs, images, source documents). The blob
|
||||
-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``;
|
||||
-- this row carries metadata + the absolute ``stored_path`` so the
|
||||
-- dashboard can list/download and ``build_worker_context`` can surface
|
||||
-- the absolute path to the worker (which has full file-tool access). See
|
||||
-- #35338.
|
||||
CREATE TABLE IF NOT EXISTS task_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Subscription from a gateway source (platform + chat + thread) to a
|
||||
-- task. The gateway's kanban-notifier watcher tails task_events and
|
||||
-- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
|
||||
@@ -981,6 +1047,7 @@ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, c
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_task ON task_attachments(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id);
|
||||
"""
|
||||
|
||||
@@ -1637,6 +1704,140 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
||||
(new, old),
|
||||
)
|
||||
|
||||
_rebuild_drifted_tables(conn)
|
||||
|
||||
|
||||
# Legacy DBs defined these tables with a ``TEXT PRIMARY KEY`` id (or, for
|
||||
# ``kanban_notify_subs``, a nullable ``TEXT last_event_id``). The current
|
||||
# schema uses ``INTEGER PRIMARY KEY AUTOINCREMENT`` / ``INTEGER NOT NULL
|
||||
# DEFAULT 0``. ``CREATE TABLE IF NOT EXISTS`` skips existing tables
|
||||
# regardless of schema and ``_add_column_if_missing`` only adds columns, so
|
||||
# neither can fix a drifted column type — the table must be rebuilt. See
|
||||
# #35096.
|
||||
#
|
||||
# Each entry pairs the canonical CREATE TABLE with the CREATE INDEX
|
||||
# statements that DROP TABLE would otherwise take down with it (including
|
||||
# ``idx_events_run``, added by the additive pass above). To guard against
|
||||
# this list drifting from SCHEMA_SQL, ``test_rebuilt_schema_matches_fresh``
|
||||
# asserts a rebuilt legacy DB is byte-identical to a fresh one.
|
||||
_REBUILD_SPECS = {
|
||||
"task_events": (
|
||||
"CREATE TABLE task_events ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, run_id INTEGER, kind TEXT NOT NULL,"
|
||||
" payload TEXT, created_at INTEGER NOT NULL)",
|
||||
(
|
||||
"CREATE INDEX idx_events_task ON task_events(task_id, created_at)",
|
||||
"CREATE INDEX idx_events_run ON task_events(run_id, id)",
|
||||
),
|
||||
),
|
||||
"task_comments": (
|
||||
"CREATE TABLE task_comments ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL,"
|
||||
" created_at INTEGER NOT NULL)",
|
||||
("CREATE INDEX idx_comments_task ON task_comments(task_id, created_at)",),
|
||||
),
|
||||
"task_runs": (
|
||||
"CREATE TABLE task_runs ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, profile TEXT, step_key TEXT,"
|
||||
" status TEXT NOT NULL, claim_lock TEXT, claim_expires INTEGER,"
|
||||
" worker_pid INTEGER, max_runtime_seconds INTEGER,"
|
||||
" last_heartbeat_at INTEGER, started_at INTEGER NOT NULL,"
|
||||
" ended_at INTEGER, outcome TEXT, summary TEXT, metadata TEXT,"
|
||||
" error TEXT)",
|
||||
(
|
||||
"CREATE INDEX idx_runs_task ON task_runs(task_id, started_at)",
|
||||
"CREATE INDEX idx_runs_status ON task_runs(status)",
|
||||
),
|
||||
),
|
||||
"kanban_notify_subs": (
|
||||
"CREATE TABLE kanban_notify_subs ("
|
||||
" task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL,"
|
||||
" thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,"
|
||||
" notifier_profile TEXT, created_at INTEGER NOT NULL,"
|
||||
" last_event_id INTEGER NOT NULL DEFAULT 0,"
|
||||
" PRIMARY KEY (task_id, platform, chat_id, thread_id))",
|
||||
("CREATE INDEX idx_notify_task ON kanban_notify_subs(task_id)",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _table_has_drifted(conn: sqlite3.Connection, table: str) -> bool:
|
||||
"""True when ``table`` still carries the legacy (pre-AUTOINCREMENT) shape."""
|
||||
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
if not info:
|
||||
return False # table absent — nothing to rebuild
|
||||
if table == "kanban_notify_subs":
|
||||
lei = next((c for c in info if c["name"] == "last_event_id"), None)
|
||||
return lei is not None and (lei["type"] or "").upper() != "INTEGER"
|
||||
# task_events / task_comments / task_runs: id must be INTEGER and a PK.
|
||||
id_col = next((c for c in info if c["name"] == "id"), None)
|
||||
if id_col is None:
|
||||
return False
|
||||
return not ((id_col["type"] or "").upper() == "INTEGER" and id_col["pk"])
|
||||
|
||||
|
||||
def _rebuild_drifted_tables(conn: sqlite3.Connection) -> None:
|
||||
"""Rebuild any kanban table whose column types drifted from SCHEMA_SQL.
|
||||
|
||||
Old boards crash the gateway notifier (``int(None)`` on a NULL id in
|
||||
``unseen_events_for_sub``) and never match the ``id > cursor`` filter, so
|
||||
every kanban notification is silently lost (#35096). Each affected table is
|
||||
rebuilt with the standard SQLite pattern — CREATE new → INSERT shared
|
||||
columns → DROP old → RENAME — recreating its indexes too (DROP TABLE takes
|
||||
them down). The legacy TEXT ids are dropped (they aren't valid integers);
|
||||
AUTOINCREMENT assigns fresh ones and ``last_event_id`` cursors reset to 0,
|
||||
so the first post-migration tick replays a task's event history once —
|
||||
the safe failure mode for a feature that was already fully broken.
|
||||
|
||||
The whole pass runs in one transaction so an interruption can't leave a
|
||||
table half-renamed, and under ``connect()``'s init locks so nothing races
|
||||
it. Idempotent: a correctly-typed DB skips every table and returns without
|
||||
opening a transaction.
|
||||
"""
|
||||
drifted = [t for t in _REBUILD_SPECS if _table_has_drifted(conn, t)]
|
||||
if not drifted:
|
||||
return
|
||||
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
for table in drifted:
|
||||
create_sql, index_sqls = _REBUILD_SPECS[table]
|
||||
old_cols = [c["name"] for c in conn.execute(f"PRAGMA table_info({table})")]
|
||||
_log.info("kanban migration: rebuilding %s to match current schema", table)
|
||||
conn.execute(f"ALTER TABLE {table} RENAME TO {table}_legacy")
|
||||
conn.execute(create_sql)
|
||||
new_cols = {c["name"] for c in conn.execute(f"PRAGMA table_info({table})")}
|
||||
if table == "kanban_notify_subs":
|
||||
# Cast the legacy TEXT cursor to INTEGER; NULL / non-numeric → 0.
|
||||
shared = [c for c in old_cols if c in new_cols and c != "last_event_id"]
|
||||
cols_csv = ", ".join(shared)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({cols_csv}, last_event_id) "
|
||||
f"SELECT {cols_csv}, COALESCE(CAST(last_event_id AS INTEGER), 0) "
|
||||
f"FROM {table}_legacy"
|
||||
)
|
||||
else:
|
||||
# Drop the legacy TEXT id; AUTOINCREMENT reassigns it.
|
||||
shared = [c for c in old_cols if c in new_cols and c != "id"]
|
||||
cols_csv = ", ".join(shared)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({cols_csv}) "
|
||||
f"SELECT {cols_csv} FROM {table}_legacy"
|
||||
)
|
||||
conn.execute(f"DROP TABLE {table}_legacy")
|
||||
for index_sql in index_sqls:
|
||||
conn.execute(index_sql)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
|
||||
"""Read the SQLite header page_count and compare against actual file size.
|
||||
@@ -2252,6 +2453,121 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_attachment(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
filename: str,
|
||||
stored_path: str,
|
||||
content_type: Optional[str] = None,
|
||||
size: int = 0,
|
||||
uploaded_by: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Record a file attachment for a task. Returns the new attachment id.
|
||||
|
||||
The caller is responsible for writing the blob to ``stored_path``
|
||||
first (under :func:`task_attachments_dir`); this only persists the
|
||||
metadata row and appends an ``attached`` event.
|
||||
"""
|
||||
if not filename or not filename.strip():
|
||||
raise ValueError("attachment filename is required")
|
||||
if not stored_path or not stored_path.strip():
|
||||
raise ValueError("attachment stored_path is required")
|
||||
now = int(time.time())
|
||||
with write_txn(conn):
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone():
|
||||
raise ValueError(f"unknown task {task_id}")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO task_attachments "
|
||||
"(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
task_id,
|
||||
filename.strip(),
|
||||
stored_path,
|
||||
content_type,
|
||||
int(size),
|
||||
uploaded_by,
|
||||
now,
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn,
|
||||
task_id,
|
||||
"attached",
|
||||
{"filename": filename.strip(), "size": int(size), "by": uploaded_by},
|
||||
)
|
||||
return int(cur.lastrowid or 0)
|
||||
|
||||
|
||||
def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
r = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE id = ?", (attachment_id,)
|
||||
).fetchone()
|
||||
if r is None:
|
||||
return None
|
||||
return Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
"""Delete an attachment row and its on-disk blob. Returns the removed row.
|
||||
|
||||
Returns ``None`` when no row matched. The blob is removed best-effort
|
||||
(a missing file is not an error); the metadata row is the source of
|
||||
truth for whether an attachment "exists".
|
||||
"""
|
||||
with write_txn(conn):
|
||||
att = get_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
return None
|
||||
conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,))
|
||||
_append_event(
|
||||
conn, att.task_id, "attachment_removed", {"filename": att.filename}
|
||||
)
|
||||
try:
|
||||
p = Path(att.stored_path)
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return att
|
||||
|
||||
|
||||
def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
@@ -2457,7 +2773,9 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
return bool(row) and row["kind"] == "blocked"
|
||||
|
||||
|
||||
def recompute_ready(conn: sqlite3.Connection) -> int:
|
||||
def recompute_ready(
|
||||
conn: sqlite3.Connection, failure_limit: int = None,
|
||||
) -> int:
|
||||
"""Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
|
||||
|
||||
Returns the number of tasks promoted. Safe to call inside or outside
|
||||
@@ -2465,17 +2783,34 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
|
||||
|
||||
``blocked`` tasks are also considered for promotion (so a task
|
||||
blocked purely by a parent dependency unblocks itself when the
|
||||
parent completes), *except* when the most recent block event was a
|
||||
worker-initiated ``kanban_block`` — those stay blocked until an
|
||||
explicit ``kanban_unblock`` (#28712). Without that guard, a
|
||||
``review-required`` handoff would auto-respawn, the fresh worker
|
||||
would find nothing to do, exit cleanly, get recorded as a protocol
|
||||
violation, and the cycle would repeat indefinitely.
|
||||
parent completes), *except* in two cases:
|
||||
|
||||
1. The most recent block event was a worker-initiated
|
||||
``kanban_block`` — those stay blocked until an explicit
|
||||
``kanban_unblock`` (#28712).
|
||||
|
||||
2. The task's ``consecutive_failures`` has reached the effective
|
||||
failure limit. This prevents infinite retry loops when a task
|
||||
repeatedly exhausts its iteration budget: without this guard the
|
||||
counter would reset on every recovery cycle and the circuit
|
||||
breaker could never trip (#35072).
|
||||
|
||||
The effective failure limit resolves in the same order as the
|
||||
circuit breaker in ``_record_task_failure`` so the two never
|
||||
disagree about when a task is permanently blocked:
|
||||
|
||||
1. per-task ``max_retries`` if set
|
||||
2. caller-supplied ``failure_limit`` (the dispatcher passes the
|
||||
``kanban.failure_limit`` config value through ``dispatch_once``)
|
||||
3. ``DEFAULT_FAILURE_LIMIT``
|
||||
"""
|
||||
if failure_limit is None:
|
||||
failure_limit = DEFAULT_FAILURE_LIMIT
|
||||
promoted = 0
|
||||
with write_txn(conn):
|
||||
todo_rows = conn.execute(
|
||||
"SELECT id, status FROM tasks WHERE status IN ('todo', 'blocked')"
|
||||
"SELECT id, status, consecutive_failures, max_retries "
|
||||
"FROM tasks WHERE status IN ('todo', 'blocked')"
|
||||
).fetchall()
|
||||
for row in todo_rows:
|
||||
task_id = row["id"]
|
||||
@@ -2493,13 +2828,25 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
if all(p["status"] in ("done", "archived") for p in parents):
|
||||
# Blocked tasks also get their failure counters reset —
|
||||
# this is effectively an auto-unblock (circuit-breaker
|
||||
# recovery; worker-initiated blocks are skipped above).
|
||||
if cur_status == "blocked":
|
||||
# Don't auto-recover tasks that have hit the
|
||||
# circuit-breaker failure limit. Without this
|
||||
# guard, a task that repeatedly exhausts its
|
||||
# iteration budget would cycle forever:
|
||||
# block → auto-recover → respawn → budget
|
||||
# exhausted → block → … The counter must also
|
||||
# be preserved so the breaker can accumulate
|
||||
# across recovery cycles.
|
||||
failures = int(row["consecutive_failures"] or 0)
|
||||
task_limit = row["max_retries"]
|
||||
effective_limit = (
|
||||
int(task_limit) if task_limit is not None
|
||||
else int(failure_limit)
|
||||
)
|
||||
if failures >= effective_limit:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', "
|
||||
"consecutive_failures = 0, last_failure_error = NULL "
|
||||
"UPDATE tasks SET status = 'ready' "
|
||||
"WHERE id = ? AND status = 'blocked'",
|
||||
(task_id,),
|
||||
)
|
||||
@@ -5424,7 +5771,7 @@ def dispatch_once(
|
||||
if _crash_auto_blocked:
|
||||
result.auto_blocked.extend(_crash_auto_blocked)
|
||||
result.timed_out = enforce_max_runtime(conn)
|
||||
result.promoted = recompute_ready(conn)
|
||||
result.promoted = recompute_ready(conn, failure_limit=failure_limit)
|
||||
|
||||
# Count tasks already running so max_spawn enforces concurrency rather
|
||||
# than a per-tick spawn budget. See the docstring above for the full
|
||||
@@ -6300,6 +6647,25 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
|
||||
lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES))
|
||||
lines.append("")
|
||||
|
||||
# Attachments — files uploaded to this task (PDFs, source docs,
|
||||
# images). Surface the absolute on-disk path so the worker, which has
|
||||
# full file-tool access, can read them directly (read_file, terminal
|
||||
# `pdftotext`, etc.). On the local terminal backend the path resolves
|
||||
# as-is; remote backends need the kanban attachments dir mounted.
|
||||
attachments = list_attachments(conn, task_id)
|
||||
if attachments:
|
||||
lines.append("## Attachments")
|
||||
lines.append(
|
||||
"Files attached to this task. Read them with the file/terminal "
|
||||
"tools at the absolute paths below:"
|
||||
)
|
||||
for att in attachments:
|
||||
size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0
|
||||
size_str = f", {size_kb} KB" if size_kb else ""
|
||||
ctype = f", {att.content_type}" if att.content_type else ""
|
||||
lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`")
|
||||
lines.append("")
|
||||
|
||||
# Prior attempts — show closed runs so a retrying worker sees the
|
||||
# history. Skip the currently-active run (that's this worker).
|
||||
# Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older
|
||||
|
||||
+300
-65
@@ -65,6 +65,46 @@ import os
|
||||
import sys
|
||||
|
||||
|
||||
def _set_process_title() -> None:
|
||||
"""Set the process title to 'hermes' so tools like 'ps', 'top', and
|
||||
'htop' show the app name instead of 'python3.xx'.
|
||||
|
||||
Purely cosmetic — non-fatal on any platform.
|
||||
|
||||
Strategy (try in order):
|
||||
1. ``setproctitle`` (opt-in dep — installed via ``hermes tools`` or
|
||||
``pip install setproctitle``, or bundled in a future release).
|
||||
2. ctypes ``prctl(PR_SET_NAME)`` (Linux only, 15-char limit).
|
||||
3. ctypes ``pthread_setname_np`` (macOS only, kernel thread name —
|
||||
changes lldb/top but not ``ps aux``).
|
||||
4. No-op on Windows (the .exe name is already ``hermes.exe``).
|
||||
"""
|
||||
# Strategy 1: setproctitle (best — works on macOS, Linux, BSD)
|
||||
try:
|
||||
import setproctitle # type: ignore[import-untyped]
|
||||
|
||||
setproctitle.setproctitle("hermes")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Strategy 2/3: platform-specific ctypes fallback
|
||||
import ctypes
|
||||
import platform
|
||||
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15
|
||||
elif system == "Darwin":
|
||||
libc = ctypes.CDLL("libc.dylib", use_errno=True)
|
||||
libc.pthread_setname_np(b"hermes")
|
||||
# Windows: the .exe name is already ``hermes.exe`` — nothing to do.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
||||
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
|
||||
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
|
||||
@@ -2385,7 +2425,12 @@ def select_provider_and_model(args=None):
|
||||
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
||||
active = "custom"
|
||||
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
|
||||
from hermes_cli.models import (
|
||||
CANONICAL_PROVIDERS,
|
||||
_PROVIDER_LABELS,
|
||||
group_providers,
|
||||
provider_group_for_slug,
|
||||
)
|
||||
|
||||
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
|
||||
if active and active in _custom_provider_map:
|
||||
@@ -2398,8 +2443,43 @@ def select_provider_and_model(args=None):
|
||||
print(f" Active provider: {active_label}")
|
||||
print()
|
||||
|
||||
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
|
||||
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
|
||||
# Step 1: Provider selection.
|
||||
#
|
||||
# Canonical providers are folded into top-level groups (display only — see
|
||||
# PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one
|
||||
# row ("Kimi / Moonshot ▸"); picking it opens a member sub-picker that
|
||||
# resolves back to a concrete slug, so the dispatch chain below is
|
||||
# unchanged. Custom providers and the trailing actions stay flat.
|
||||
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
|
||||
grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS])
|
||||
|
||||
# The group/slug that should be pre-selected: the active provider's group
|
||||
# if it's grouped, otherwise the active slug itself.
|
||||
active_group = provider_group_for_slug(active) if active else ""
|
||||
|
||||
# ordered entries: (key, label, members)
|
||||
# members == [] → leaf row, key is a provider slug / action
|
||||
# members != [] → group row, key is "group:<gid>"
|
||||
ordered: list[tuple[str, str, list[str]]] = []
|
||||
default_idx = 0
|
||||
for row in grouped_rows:
|
||||
if row["kind"] == "group":
|
||||
gid = row["group_id"]
|
||||
label = f"{row['label']} ▸"
|
||||
key = f"group:{gid}"
|
||||
is_active = bool(active_group) and gid == active_group
|
||||
members = row["members"]
|
||||
else:
|
||||
slug = row["slug"]
|
||||
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
|
||||
key = slug
|
||||
is_active = bool(active) and slug == active
|
||||
members = []
|
||||
if is_active:
|
||||
ordered.append((key, f"{label} ← currently active", members))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label, members))
|
||||
|
||||
for key, provider_info in _custom_provider_map.items():
|
||||
name = provider_info["name"]
|
||||
@@ -2407,36 +2487,49 @@ def select_provider_and_model(args=None):
|
||||
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
saved_model = provider_info.get("model", "")
|
||||
model_hint = f" — {saved_model}" if saved_model else ""
|
||||
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
|
||||
# Build the menu
|
||||
ordered = []
|
||||
default_idx = 0
|
||||
for key, label in all_providers:
|
||||
label = f"{name} ({short_url}){model_hint}"
|
||||
if active and key == active:
|
||||
ordered.append((key, f"{label} ← currently active"))
|
||||
ordered.append((key, f"{label} ← currently active", []))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label))
|
||||
ordered.append((key, label, []))
|
||||
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
|
||||
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(
|
||||
config.get("custom_providers")
|
||||
)
|
||||
if _has_saved_custom_list:
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider"))
|
||||
ordered.append(("aux-config", "Configure auxiliary models..."))
|
||||
ordered.append(("cancel", "Leave unchanged"))
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider", []))
|
||||
ordered.append(("aux-config", "Configure auxiliary models...", []))
|
||||
ordered.append(("cancel", "Leave unchanged", []))
|
||||
|
||||
provider_idx = _prompt_provider_choice(
|
||||
[label for _, label in ordered],
|
||||
[label for _, label, _ in ordered],
|
||||
default=default_idx,
|
||||
)
|
||||
if provider_idx is None or ordered[provider_idx][0] == "cancel":
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
selected_provider = ordered[provider_idx][0]
|
||||
selected_key = ordered[provider_idx][0]
|
||||
selected_members = ordered[provider_idx][2]
|
||||
|
||||
# Group row → drill into a member sub-picker. Default to the active member
|
||||
# if the active provider lives in this group.
|
||||
if selected_members:
|
||||
member_default = 0
|
||||
if active in selected_members:
|
||||
member_default = selected_members.index(active)
|
||||
member_labels = [
|
||||
canonical_descs.get(m, provider_labels.get(m, m)) for m in selected_members
|
||||
]
|
||||
member_idx = _prompt_provider_choice(member_labels, default=member_default)
|
||||
if member_idx is None:
|
||||
print("No change.")
|
||||
return
|
||||
selected_provider = selected_members[member_idx]
|
||||
else:
|
||||
selected_provider = selected_key
|
||||
|
||||
if selected_provider == "aux-config":
|
||||
_aux_config_menu()
|
||||
@@ -8008,39 +8101,6 @@ def _detect_concurrent_hermes_instances(
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Build a set of PIDs to exclude: the Python process itself plus its
|
||||
# entire parent chain. On Windows the setuptools-generated hermes.exe
|
||||
# launcher is a separate native process that spawns python.exe (the
|
||||
# interpreter that runs our code). os.getpid() returns the Python PID,
|
||||
# but the launcher (which holds the file lock) is the parent. Without
|
||||
# walking the parent chain, every ``hermes update`` reports its own
|
||||
# launcher as a concurrent instance — a false positive.
|
||||
if exclude_pid is not None:
|
||||
exclude_pids: set[int] = {exclude_pid}
|
||||
else:
|
||||
exclude_pids = {os.getpid()}
|
||||
# The parent-walk is best-effort: if psutil rejects a PID (NoSuchProcess /
|
||||
# AccessDenied) we stop walking and use whatever we've collected so far.
|
||||
# Broader Exception catch on the outer block guards against partially-
|
||||
# stubbed psutil in unit tests (e.g. a SimpleNamespace lacking Process /
|
||||
# NoSuchProcess) — the surrounding update flow documents this helper as
|
||||
# "never raises".
|
||||
try:
|
||||
current = psutil.Process(next(iter(exclude_pids)))
|
||||
while True:
|
||||
try:
|
||||
parent = current.parent()
|
||||
except Exception:
|
||||
break
|
||||
if parent is None or parent.pid <= 0:
|
||||
break
|
||||
if parent.pid in exclude_pids:
|
||||
break # loop detected
|
||||
exclude_pids.add(parent.pid)
|
||||
current = parent
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve every shim path to its canonical form once for cheap comparison.
|
||||
shim_paths: set[str] = set()
|
||||
for shim in _hermes_exe_shims(scripts_dir):
|
||||
@@ -8051,6 +8111,56 @@ def _detect_concurrent_hermes_instances(
|
||||
if not shim_paths:
|
||||
return []
|
||||
|
||||
# Build a set of PIDs to exclude: the Python process itself plus every
|
||||
# ancestor whose executable is one of our shims. On Windows the
|
||||
# setuptools-generated hermes.exe launcher is a separate native process
|
||||
# that spawns python.exe (the interpreter that runs our code).
|
||||
# os.getpid() returns the Python PID, but the launcher (which holds the
|
||||
# file lock) is the parent. Without excluding it, every ``hermes update``
|
||||
# reports its own launcher as a concurrent instance — a false positive
|
||||
# (issues #29341, #34795).
|
||||
#
|
||||
# Two robustness points learned from the field:
|
||||
# 1. Use ``proc.parents()`` — it returns the WHOLE ancestor list in one
|
||||
# call. The earlier per-hop ``current.parent()`` loop bailed on the
|
||||
# first psutil error (AccessDenied/NoSuchProcess is common on Windows
|
||||
# across session/elevation boundaries), leaving the launcher shim in
|
||||
# the candidate set and re-triggering the false positive.
|
||||
# 2. Only exclude ancestors whose exe is itself a shim. A genuine second
|
||||
# hermes.exe sitting *under* a non-Hermes parent (e.g. a Hermes
|
||||
# Desktop backend child) must still be flagged, so we don't blanket-
|
||||
# exclude unrelated ancestors like the shell or terminal.
|
||||
# Broad ``except Exception`` guards against partially-stubbed psutil in
|
||||
# unit tests; this helper is documented as "never raises".
|
||||
if exclude_pid is not None:
|
||||
exclude_pids: set[int] = {int(exclude_pid)}
|
||||
else:
|
||||
exclude_pids = {os.getpid()}
|
||||
try:
|
||||
seed = next(iter(exclude_pids))
|
||||
try:
|
||||
ancestors = psutil.Process(seed).parents()
|
||||
except Exception:
|
||||
ancestors = []
|
||||
for ancestor in ancestors:
|
||||
try:
|
||||
anc_exe = ancestor.exe()
|
||||
except Exception:
|
||||
continue
|
||||
if not anc_exe:
|
||||
continue
|
||||
try:
|
||||
anc_norm = str(Path(anc_exe).resolve()).lower()
|
||||
except (OSError, ValueError):
|
||||
anc_norm = str(anc_exe).lower()
|
||||
if anc_norm in shim_paths:
|
||||
try:
|
||||
exclude_pids.add(int(ancestor.pid))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
matches: list[tuple[int, str]] = []
|
||||
try:
|
||||
proc_iter = psutil.process_iter(["pid", "exe", "name"])
|
||||
@@ -8091,6 +8201,13 @@ def _format_concurrent_instances_message(
|
||||
lines.append("")
|
||||
lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and")
|
||||
lines.append(" stop the gateway (`hermes gateway stop`) before retrying.")
|
||||
lines.append("")
|
||||
if matches:
|
||||
pid_args = " ".join(f"/PID {pid}" for pid, _ in matches)
|
||||
lines.append(" If you've already closed everything and these PIDs are")
|
||||
lines.append(" stale, terminate them directly, then retry the update:")
|
||||
lines.append(f" taskkill {pid_args} /F")
|
||||
lines.append("")
|
||||
lines.append(" Override with `hermes update --force` if you've already")
|
||||
lines.append(" confirmed those processes will not write to the venv.")
|
||||
return "\n".join(lines)
|
||||
@@ -9055,18 +9172,51 @@ def cmd_update(args):
|
||||
def _cmd_update_pip(args):
|
||||
"""Update Hermes via pip (for PyPI installs)."""
|
||||
from hermes_cli import __version__
|
||||
from hermes_cli.config import is_uv_tool_install
|
||||
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
pipx = shutil.which("pipx") if pipx_managed else None
|
||||
|
||||
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
|
||||
# exported (uv refuses to install without it when the launcher shim
|
||||
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
|
||||
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
|
||||
# set it for them.
|
||||
export_virtualenv = False
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
elif pipx_managed and pipx:
|
||||
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
|
||||
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
|
||||
cmd = [pipx, "upgrade", "hermes-agent"]
|
||||
elif uv:
|
||||
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
|
||||
if in_venv:
|
||||
# Launcher shim runs the venv interpreter but doesn't export
|
||||
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
|
||||
export_virtualenv = True
|
||||
else:
|
||||
# Outside any venv, ``--system`` lets uv target the active
|
||||
# interpreter, matching pip's default behaviour.
|
||||
cmd.insert(3, "--system")
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
|
||||
print(f"→ Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
run_kwargs = {}
|
||||
if export_virtualenv:
|
||||
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
|
||||
result = subprocess.run(cmd, **run_kwargs)
|
||||
if result.returncode != 0:
|
||||
print("✗ Update failed")
|
||||
sys.exit(1)
|
||||
@@ -11157,6 +11307,13 @@ def cmd_completion(args, parser=None):
|
||||
print(generate_bash(parser))
|
||||
|
||||
|
||||
def cmd_prompt_size(args):
|
||||
"""Show a byte/char breakdown of the system prompt + tool schemas."""
|
||||
from hermes_cli.prompt_size import cmd_prompt_size as _impl
|
||||
|
||||
_impl(args)
|
||||
|
||||
|
||||
def cmd_logs(args):
|
||||
"""View and filter Hermes log files."""
|
||||
from hermes_cli.logs import tail_log, list_logs
|
||||
@@ -11193,6 +11350,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"prompt-size",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
"version", "webhook", "whatsapp", "chat", "secrets", "security",
|
||||
@@ -11293,6 +11451,26 @@ _AGENT_SUBCOMMANDS = {
|
||||
}
|
||||
|
||||
|
||||
def _is_tui_chat_launch(args) -> bool:
|
||||
return bool(getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1")
|
||||
|
||||
|
||||
def _command_has_dedicated_mcp_startup(args) -> bool:
|
||||
if args.command == "acp":
|
||||
return True
|
||||
if args.command == "gateway" and getattr(args, "gateway_command", None) == "run":
|
||||
return True
|
||||
if args.command == "cron" and getattr(args, "cron_command", None) in {"run", "tick"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _should_background_mcp_startup(args) -> bool:
|
||||
if _is_tui_chat_launch(args):
|
||||
return False
|
||||
return args.command in {None, "chat", "rl"}
|
||||
|
||||
|
||||
def _prepare_agent_startup(args) -> None:
|
||||
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
|
||||
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
||||
@@ -11312,19 +11490,42 @@ def _prepare_agent_startup(args) -> None:
|
||||
"plugin discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
# MCP tool discovery — no event loop running in CLI/TUI startup,
|
||||
# so inline is safe. Moved here from model_tools.py module scope
|
||||
# to avoid freezing the gateway's event loop on its first message
|
||||
# via the same lazy import path (#16856).
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
_run_inline_mcp_discovery = True
|
||||
if _is_tui_chat_launch(args):
|
||||
# The TUI launcher hands off to a dedicated startup path that already
|
||||
# backgrounds MCP discovery with a bounded join before the first tool
|
||||
# snapshot.
|
||||
_run_inline_mcp_discovery = False
|
||||
elif _command_has_dedicated_mcp_startup(args):
|
||||
# These entrypoints already do their own MCP startup later on the real
|
||||
# runtime path (gateway executor, ACP launcher, cron job runner).
|
||||
_run_inline_mcp_discovery = False
|
||||
elif _should_background_mcp_startup(args):
|
||||
try:
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
start_background_mcp_discovery(
|
||||
logger=logger,
|
||||
thread_name="cli-mcp-discovery",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Background MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
_run_inline_mcp_discovery = False
|
||||
if _run_inline_mcp_discovery:
|
||||
try:
|
||||
# MCP tool discovery remains synchronous for entrypoints that do
|
||||
# not own a later bounded/executor startup path.
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
@@ -11465,6 +11666,10 @@ def _try_termux_fast_tui_launch() -> bool:
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
# Cosmetic: make the process show up as 'hermes' instead of 'python3.11'
|
||||
# in ps/top/htop. Non-fatal — just a nicer UX.
|
||||
_set_process_title()
|
||||
|
||||
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
|
||||
try:
|
||||
from hermes_cli.stdio import configure_windows_stdio
|
||||
@@ -13218,9 +13423,15 @@ Examples:
|
||||
),
|
||||
)
|
||||
memory_sub = memory_parser.add_subparsers(dest="memory_command")
|
||||
memory_sub.add_parser(
|
||||
_setup_parser = memory_sub.add_parser(
|
||||
"setup", help="Interactive provider selection and configuration"
|
||||
)
|
||||
_setup_parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Provider to configure directly (e.g. honcho), skipping the picker",
|
||||
)
|
||||
memory_sub.add_parser("status", help="Show current memory provider config")
|
||||
memory_sub.add_parser("off", help="Disable external provider (built-in only)")
|
||||
_reset_parser = memory_sub.add_parser(
|
||||
@@ -14471,6 +14682,30 @@ Examples:
|
||||
)
|
||||
logs_parser.set_defaults(func=cmd_logs)
|
||||
|
||||
# =========================================================================
|
||||
# prompt-size command
|
||||
# =========================================================================
|
||||
prompt_size_parser = subparsers.add_parser(
|
||||
"prompt-size",
|
||||
help="Show a byte breakdown of the system prompt + tool schemas",
|
||||
description=(
|
||||
"Report the fixed prompt budget for a fresh session: system "
|
||||
"prompt total, skills index, memory, user profile, and tool-schema "
|
||||
"JSON. Runs offline (no API call)."
|
||||
),
|
||||
)
|
||||
prompt_size_parser.add_argument(
|
||||
"--platform",
|
||||
default="cli",
|
||||
help="Platform to simulate (cli, telegram, discord, ...). Default: cli",
|
||||
)
|
||||
prompt_size_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit the breakdown as JSON",
|
||||
)
|
||||
prompt_size_parser.set_defaults(func=cmd_prompt_size)
|
||||
|
||||
# =========================================================================
|
||||
# Parse and execute
|
||||
# =========================================================================
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Shared CLI/TUI-safe helpers for background MCP discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
_mcp_discovery_lock = threading.Lock()
|
||||
_mcp_discovery_started = False
|
||||
_mcp_discovery_thread: Optional[threading.Thread] = None
|
||||
|
||||
|
||||
def _has_configured_mcp_servers() -> bool:
|
||||
"""Cheap config probe so non-MCP users avoid importing the MCP stack."""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
|
||||
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
|
||||
except Exception:
|
||||
# Be conservative: if config probing fails, try discovery in the
|
||||
# background so startup still can't block.
|
||||
return True
|
||||
|
||||
|
||||
def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
|
||||
"""Spawn one shared background MCP discovery thread for this process."""
|
||||
global _mcp_discovery_started, _mcp_discovery_thread
|
||||
|
||||
with _mcp_discovery_lock:
|
||||
if _mcp_discovery_started:
|
||||
return
|
||||
_mcp_discovery_started = True
|
||||
if not _has_configured_mcp_servers():
|
||||
return
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug("Background MCP tool discovery failed", exc_info=True)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_discover,
|
||||
name=thread_name,
|
||||
daemon=True,
|
||||
)
|
||||
_mcp_discovery_thread = thread
|
||||
thread.start()
|
||||
|
||||
|
||||
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
|
||||
"""Briefly wait for background MCP discovery before the first tool snapshot."""
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is None or not thread.is_alive():
|
||||
return
|
||||
thread.join(timeout=timeout)
|
||||
@@ -452,7 +452,11 @@ def memory_command(args) -> None:
|
||||
"""Route memory subcommands."""
|
||||
sub = getattr(args, "memory_command", None)
|
||||
if sub == "setup":
|
||||
cmd_setup(args)
|
||||
provider = getattr(args, "provider", None)
|
||||
if provider:
|
||||
cmd_setup_provider(provider)
|
||||
else:
|
||||
cmd_setup(args)
|
||||
elif sub == "status":
|
||||
cmd_status(args)
|
||||
else:
|
||||
|
||||
@@ -936,6 +936,105 @@ _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
|
||||
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider groups — DISPLAY ONLY
|
||||
#
|
||||
# Some vendors expose several Hermes provider slugs (one per endpoint /
|
||||
# auth method: global API, China API, OAuth coding plan, ...). Listing every
|
||||
# slug as a top-level row in the interactive `hermes model` / setup wizard /
|
||||
# Telegram `/model` pickers makes that list long and noisy.
|
||||
#
|
||||
# These groups fold related slugs under one top-level row in INTERACTIVE
|
||||
# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,
|
||||
# the ``--provider`` flag, ``/model <provider:model>``, or any typed path —
|
||||
# every member slug remains individually addressable. Grouping is a pure
|
||||
# display affordance; ``group_providers()`` is the single fold used by all
|
||||
# three picker surfaces so they stay consistent.
|
||||
#
|
||||
# group_id -> (display_label, [member_slug, ...])
|
||||
#
|
||||
# Member order is the order shown inside the group submenu.
|
||||
# ---------------------------------------------------------------------------
|
||||
PROVIDER_GROUPS: dict[str, tuple[str, list[str]]] = {
|
||||
"kimi": ("Kimi / Moonshot", ["kimi-coding", "kimi-coding-cn"]),
|
||||
"minimax": ("MiniMax", ["minimax", "minimax-oauth", "minimax-cn"]),
|
||||
"xai": ("xAI Grok", ["xai", "xai-oauth"]),
|
||||
"google": ("Google Gemini", ["gemini", "google-gemini-cli"]),
|
||||
"openai": ("OpenAI", ["openai-codex", "openai-api"]),
|
||||
"opencode": ("OpenCode", ["opencode-zen", "opencode-go"]),
|
||||
"copilot": ("GitHub Copilot", ["copilot", "copilot-acp"]),
|
||||
}
|
||||
|
||||
# Reverse index: member slug -> group_id. Built once at import.
|
||||
_SLUG_TO_GROUP: dict[str, str] = {
|
||||
slug: gid for gid, (_label, members) in PROVIDER_GROUPS.items() for slug in members
|
||||
}
|
||||
|
||||
|
||||
def provider_group_for_slug(slug: str) -> str:
|
||||
"""Return the group_id a provider slug belongs to, or "" if ungrouped."""
|
||||
return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "")
|
||||
|
||||
|
||||
def group_providers(slugs):
|
||||
"""Fold a flat ordered slug iterable into picker rows by provider group.
|
||||
|
||||
DISPLAY ONLY. Used by every interactive picker (``hermes model``, the
|
||||
setup wizard, the Telegram ``/model`` keyboard) so grouping is identical
|
||||
across surfaces.
|
||||
|
||||
Each returned row is a dict::
|
||||
|
||||
{"kind": "single", "slug": <slug>} # ungrouped, or
|
||||
# 1-member group
|
||||
{"kind": "group", "group_id": <gid>, "label": <label>,
|
||||
"members": [<slug>, ...]} # 2+ members
|
||||
|
||||
Rules:
|
||||
* A group row appears at the position of its FIRST present member, in
|
||||
the input order. Subsequent members fold into that row (and are not
|
||||
emitted again).
|
||||
* Member order inside a group follows ``PROVIDER_GROUPS`` declaration,
|
||||
restricted to the members actually present in ``slugs``.
|
||||
* A group reduced to a single present member degrades to a ``single``
|
||||
row — no pointless one-item submenu.
|
||||
* Slugs not in any group pass through as ``single`` rows, order
|
||||
preserved.
|
||||
* Duplicate slugs in the input are ignored after first sight.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
# Which present members each group has, in declaration order.
|
||||
group_members: dict[str, list[str]] = {}
|
||||
for gid, (_label, members) in PROVIDER_GROUPS.items():
|
||||
present = [m for m in members if m in set(slugs)]
|
||||
if present:
|
||||
group_members[gid] = present
|
||||
|
||||
rows = []
|
||||
emitted_groups: set[str] = set()
|
||||
for slug in slugs:
|
||||
s = str(slug or "").strip().lower()
|
||||
if not s or s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
gid = _SLUG_TO_GROUP.get(s, "")
|
||||
if not gid:
|
||||
rows.append({"kind": "single", "slug": s})
|
||||
continue
|
||||
if gid in emitted_groups:
|
||||
continue # already folded at the first member's position
|
||||
emitted_groups.add(gid)
|
||||
members = group_members.get(gid, [s])
|
||||
if len(members) <= 1:
|
||||
rows.append({"kind": "single", "slug": members[0]})
|
||||
else:
|
||||
label, _ = PROVIDER_GROUPS[gid]
|
||||
rows.append(
|
||||
{"kind": "group", "group_id": gid, "label": label, "members": list(members)}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
"glm": "zai",
|
||||
"z-ai": "zai",
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
@@ -15,6 +16,7 @@ NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "
|
||||
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
|
||||
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -302,10 +304,11 @@ def _fresh_account_info(
|
||||
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
|
||||
cache_key = _cache_key(access_token, portal_base_url)
|
||||
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
|
||||
payload = _fetch_nous_account_info(access_token, portal_base_url)
|
||||
if not payload:
|
||||
@@ -327,7 +330,8 @@ def _fresh_account_info(
|
||||
state=refreshed_state,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
return info
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
|
||||
@@ -587,9 +587,20 @@ def apply_nous_managed_defaults(
|
||||
changed.add("browser")
|
||||
|
||||
if "image_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
image_cfg = config.get("image_gen")
|
||||
if not isinstance(image_cfg, dict):
|
||||
image_cfg = {}
|
||||
config["image_gen"] = image_cfg
|
||||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
video_cfg = config.get("video_gen")
|
||||
if not isinstance(video_cfg, dict):
|
||||
video_cfg = {}
|
||||
config["video_gen"] = video_cfg
|
||||
video_cfg["provider"] = "fal"
|
||||
video_cfg["use_gateway"] = True
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
+39
-12
@@ -174,28 +174,55 @@ def run_oneshot(
|
||||
# Redirect stderr AND stdout to devnull for the entire call tree.
|
||||
# We'll print the final response to the real stdout at the end.
|
||||
real_stdout = sys.stdout
|
||||
real_stderr = sys.stderr
|
||||
devnull = open(os.devnull, "w", encoding="utf-8")
|
||||
|
||||
response: Optional[str] = None
|
||||
failure: BaseException | None = None
|
||||
try:
|
||||
with redirect_stdout(devnull), redirect_stderr(devnull):
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
try:
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
except BaseException as exc: # noqa: BLE001
|
||||
# Capture anything that escapes the agent (including OSError
|
||||
# from prompt_toolkit/Vt100 when stdout is a non-TTY pipe,
|
||||
# KeyboardInterrupt, SystemExit, etc.) so we can surface it on
|
||||
# the real stderr instead of crashing past the redirect with a
|
||||
# traceback that the caller never sees. A silent exit in a
|
||||
# cron / SSH / subprocess context is the worst failure mode.
|
||||
# See #30623.
|
||||
failure = exc
|
||||
finally:
|
||||
try:
|
||||
devnull.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if response:
|
||||
real_stdout.write(response)
|
||||
if not response.endswith("\n"):
|
||||
real_stdout.write("\n")
|
||||
real_stdout.flush()
|
||||
if failure is not None:
|
||||
# Re-raise control-flow exceptions so the parent handles them as usual
|
||||
# (Ctrl-C / explicit sys.exit() inside the agent).
|
||||
if isinstance(failure, (KeyboardInterrupt, SystemExit)):
|
||||
raise failure
|
||||
real_stderr.write(f"hermes -z: agent failed: {failure}\n")
|
||||
real_stderr.flush()
|
||||
return 1
|
||||
|
||||
if not (response or "").strip():
|
||||
real_stderr.write("hermes -z: no final response was produced; treating the run as failed.\n")
|
||||
real_stderr.flush()
|
||||
return 1
|
||||
|
||||
assert response is not None # narrowed by the empty-response guard above
|
||||
real_stdout.write(response)
|
||||
if not response.endswith("\n"):
|
||||
real_stdout.write("\n")
|
||||
real_stdout.flush()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+14
-7
@@ -1471,8 +1471,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
|
||||
def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:
|
||||
"""Rename Honcho host blocks for a renamed profile without changing peers."""
|
||||
old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes.{new_name}"
|
||||
old_host = f"hermes_{old_name}"
|
||||
legacy_old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes_{new_name}"
|
||||
|
||||
candidates = [
|
||||
new_dir / "honcho.json",
|
||||
@@ -1496,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
||||
continue
|
||||
|
||||
hosts = raw.get("hosts")
|
||||
if not isinstance(hosts, dict) or old_host not in hosts:
|
||||
if not isinstance(hosts, dict):
|
||||
continue
|
||||
source_host = old_host if old_host in hosts else legacy_old_host
|
||||
if source_host not in hosts:
|
||||
continue
|
||||
|
||||
if new_host in hosts:
|
||||
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
|
||||
continue
|
||||
|
||||
block = hosts[old_host]
|
||||
block = hosts[source_host]
|
||||
if isinstance(block, dict) and "aiPeer" not in block:
|
||||
bare = old_host.split(".", 1)[1] if "." in old_host else old_host
|
||||
if source_host.startswith("hermes_"):
|
||||
bare = source_host.split("_", 1)[1]
|
||||
else:
|
||||
bare = source_host.split(".", 1)[1] if "." in source_host else source_host
|
||||
block["aiPeer"] = bare
|
||||
hosts[new_host] = hosts.pop(old_host)
|
||||
hosts[new_host] = hosts.pop(source_host)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
@@ -1519,7 +1526,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
||||
pass
|
||||
continue
|
||||
|
||||
print(f"✓ Honcho host updated: {old_host} → {new_host}")
|
||||
print(f"✓ Honcho host updated: {source_host} → {new_host}")
|
||||
|
||||
|
||||
def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Prompt-size diagnostic: ``hermes prompt-size``.
|
||||
|
||||
Reports a byte/char breakdown of the system prompt the agent would build for
|
||||
a fresh session — system prompt total, the ``<available_skills>`` index,
|
||||
memory + user profile, and tool-schema JSON. Lets users see where their fixed
|
||||
prompt budget goes (issue #34667) without parsing a saved session JSON by hand.
|
||||
|
||||
The diagnostic builds a real inspection agent (so the numbers match what
|
||||
actually ships on the wire) but never makes a network call: it passes dummy
|
||||
credentials so ``AIAgent.__init__`` takes the direct-construction path, then
|
||||
calls ``build_system_prompt_parts`` / inspects ``agent.tools`` offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
# The skills index is wrapped in this tag pair inside the stable tier.
|
||||
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
|
||||
|
||||
|
||||
def _bytes(s: str) -> int:
|
||||
return len(s.encode("utf-8"))
|
||||
|
||||
|
||||
def _build_inspection_agent(platform: str) -> Any:
|
||||
"""Construct an offline AIAgent for prompt inspection.
|
||||
|
||||
Dummy ``api_key`` + ``base_url`` force the direct-construction path in
|
||||
``run_agent.py`` (no provider auto-detection, no network). Toolsets and
|
||||
platform come from the caller so the breakdown matches a real session.
|
||||
"""
|
||||
from run_agent import AIAgent
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
|
||||
model = model_cfg.get("default") or model_cfg.get("model") or ""
|
||||
|
||||
return AIAgent(
|
||||
model=model,
|
||||
api_key="inspect-only",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
save_trajectories=False,
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
|
||||
def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]:
|
||||
"""Return a dict of prompt-size measurements for a fresh session.
|
||||
|
||||
Keys: ``system_prompt`` (chars/bytes), ``skills_index``, ``memory``,
|
||||
``user_profile``, ``tools`` (count + json bytes), and ``sections`` (a list
|
||||
of (label, chars, bytes) for the three prompt tiers).
|
||||
"""
|
||||
from agent.system_prompt import build_system_prompt, build_system_prompt_parts
|
||||
|
||||
agent = _build_inspection_agent(platform)
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
full = build_system_prompt(agent)
|
||||
|
||||
stable = parts.get("stable", "")
|
||||
context = parts.get("context", "")
|
||||
volatile = parts.get("volatile", "")
|
||||
|
||||
# Skills index — the <available_skills> block (the largest single block
|
||||
# when many skills are installed). Measured inside the stable tier.
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_index = skills_match.group(0) if skills_match else ""
|
||||
|
||||
# Memory + user profile live in the volatile tier. We re-derive their
|
||||
# blocks directly from the memory store so the numbers are attributable
|
||||
# even though they're joined into ``volatile``.
|
||||
memory_block = ""
|
||||
user_block = ""
|
||||
store = getattr(agent, "_memory_store", None)
|
||||
if store is not None:
|
||||
try:
|
||||
if getattr(agent, "_memory_enabled", True):
|
||||
memory_block = store.format_for_system_prompt("memory") or ""
|
||||
if getattr(agent, "_user_profile_enabled", True):
|
||||
user_block = store.format_for_system_prompt("user") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool-schema JSON — the other half of the fixed per-call payload.
|
||||
tools = getattr(agent, "tools", None) or []
|
||||
tools_json = json.dumps(tools, ensure_ascii=False)
|
||||
|
||||
sections: List[Tuple[str, int, int]] = [
|
||||
("stable (identity/guidance/skills)", len(stable), _bytes(stable)),
|
||||
("context (AGENTS.md/cwd files)", len(context), _bytes(context)),
|
||||
("volatile (memory/profile/timestamp)", len(volatile), _bytes(volatile)),
|
||||
]
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
"system_prompt": {"chars": len(full), "bytes": _bytes(full)},
|
||||
"skills_index": {"chars": len(skills_index), "bytes": _bytes(skills_index)},
|
||||
"memory": {"chars": len(memory_block), "bytes": _bytes(memory_block)},
|
||||
"user_profile": {"chars": len(user_block), "bytes": _bytes(user_block)},
|
||||
"tools": {"count": len(tools), "json_bytes": _bytes(tools_json)},
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def _fmt_kb(n: int) -> str:
|
||||
return f"{n / 1024:.1f} KB"
|
||||
|
||||
|
||||
def render_breakdown(data: Dict[str, Any]) -> str:
|
||||
"""Render the breakdown as plain text suitable for a terminal."""
|
||||
lines: List[str] = []
|
||||
sp = data["system_prompt"]
|
||||
lines.append(f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})")
|
||||
lines.append("")
|
||||
lines.append(f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)")
|
||||
lines.append("")
|
||||
lines.append(" Major blocks:")
|
||||
si = data["skills_index"]
|
||||
mem = data["memory"]
|
||||
up = data["user_profile"]
|
||||
lines.append(f" skills index : {si['bytes']:>8,} B ({_fmt_kb(si['bytes'])})")
|
||||
lines.append(f" memory : {mem['bytes']:>8,} B ({_fmt_kb(mem['bytes'])})")
|
||||
lines.append(f" user profile : {up['bytes']:>8,} B ({_fmt_kb(up['bytes'])})")
|
||||
lines.append("")
|
||||
lines.append(" Prompt tiers:")
|
||||
for label, chars, byts in data["sections"]:
|
||||
lines.append(f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})")
|
||||
lines.append("")
|
||||
tools = data["tools"]
|
||||
lines.append(f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_prompt_size(args: Any) -> None:
|
||||
"""Entry point for ``hermes prompt-size``."""
|
||||
platform = getattr(args, "platform", "cli") or "cli"
|
||||
as_json = getattr(args, "json", False)
|
||||
try:
|
||||
data = compute_prompt_breakdown(platform)
|
||||
except Exception as e:
|
||||
print(f"Could not compute prompt-size breakdown: {e}")
|
||||
return
|
||||
if as_json:
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(render_breakdown(data))
|
||||
@@ -4168,10 +4168,19 @@ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Check if the WebSocket client IP is acceptable.
|
||||
|
||||
Loopback mode: only loopback clients allowed — the legacy
|
||||
Loopback bind: only loopback clients allowed — the legacy
|
||||
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
|
||||
don't want LAN hosts guessing tokens.
|
||||
|
||||
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
|
||||
specific address such as a Tailscale/LAN IP, always with
|
||||
``--insecure``): allow any peer. The operator explicitly opted into
|
||||
non-loopback exposure, so the loopback-only peer restriction does not
|
||||
apply. DNS-rebinding is still blocked by the Host/Origin guard in
|
||||
:func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and
|
||||
requires the Host header to match the bound interface — the same
|
||||
defence ``_is_accepted_host`` applies to non-loopback HTTP requests.
|
||||
|
||||
Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True``
|
||||
(enabled when the OAuth gate is active so cookies can pick up
|
||||
``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the
|
||||
@@ -4182,6 +4191,14 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""
|
||||
if getattr(app.state, "auth_required", False):
|
||||
return True
|
||||
# Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN /
|
||||
# Tailscale address) means the operator opted into non-loopback
|
||||
# access via --insecure. The loopback-only peer gate only applies to
|
||||
# an actual loopback bind; otherwise the WS handshake is rejected even
|
||||
# though same-bind HTTP requests pass _is_accepted_host.
|
||||
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
|
||||
if bound_host and bound_host not in _LOOPBACK_HOSTS:
|
||||
return True
|
||||
client_host = ws.client.host if ws.client else ""
|
||||
if not client_host:
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user