From ad812caaf8a7558b9ea066cdef7b36107f4d7158 Mon Sep 17 00:00:00 2001 From: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Date: Tue, 12 May 2026 20:31:37 +0100 Subject: [PATCH 1/4] fix: improve registry error messages and formatting - Better error messages when tool modules fail to import - Warning when check_fn raises (was silently swallowed) - Format __slots__ and __init__ for readability --- tools/registry.py | 138 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 29 deletions(-) diff --git a/tools/registry.py b/tools/registry.py index 9cac53084bd8..0690eda961f3 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -56,7 +56,9 @@ def _module_registers_tools(module_path: Path) -> bool: def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]: """Import built-in self-registering tool modules and return their module names.""" - tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent + tools_path = ( + Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent + ) module_names = [ f"tools.{path.stem}" for path in sorted(tools_path.glob("*.py")) @@ -70,7 +72,13 @@ def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]: importlib.import_module(mod_name) imported.append(mod_name) except Exception as e: - logger.warning("Could not import tool module %s: %s", mod_name, e) + logger.warning( + "Could not import tool module %s: %s. " + "Check that all dependencies are installed " + "(see 'hermes docs' or pip install hermes-agent[full]).", + mod_name, + e, + ) return imported @@ -78,14 +86,33 @@ class ToolEntry: """Metadata for a single registered tool.""" __slots__ = ( - "name", "toolset", "schema", "handler", "check_fn", - "requires_env", "is_async", "description", "emoji", - "max_result_size_chars", "dynamic_schema_overrides", + "name", + "toolset", + "schema", + "handler", + "check_fn", + "requires_env", + "is_async", + "description", + "emoji", + "max_result_size_chars", + "dynamic_schema_overrides", ) - def __init__(self, name, toolset, schema, handler, check_fn, - requires_env, is_async, description, emoji, - max_result_size_chars=None, dynamic_schema_overrides=None): + def __init__( + self, + name, + toolset, + schema, + handler, + check_fn, + requires_env, + is_async, + description, + emoji, + max_result_size_chars=None, + dynamic_schema_overrides=None, + ): self.name = name self.toolset = toolset self.schema = schema @@ -134,7 +161,13 @@ def _check_fn_cached(fn: Callable) -> bool: return value try: value = bool(fn()) - except Exception: + except Exception as exc: + logger.warning( + "Tool check_fn raised %s: %s. " + "Tool will be marked unavailable. Check dependencies or config.", + type(exc).__name__, + exc, + ) value = False with _check_fn_cache_lock: _check_fn_cache[fn] = (now, value) @@ -185,8 +218,15 @@ def _evaluate_toolset_check(self, toolset: str, check: Callable | None) -> bool: return True try: return bool(check()) - except Exception: - logger.debug("Toolset %s check raised; marking unavailable", toolset) + except Exception as exc: + logger.warning( + "Toolset '%s' check raised %s: %s. " + "Marking entire toolset unavailable. " + "Check dependencies or config for this toolset.", + toolset, + type(exc).__name__, + exc, + ) return False def get_entry(self, name: str) -> Optional[ToolEntry]: @@ -201,8 +241,7 @@ def get_registered_toolset_names(self) -> List[str]: def get_tool_names_for_toolset(self, toolset: str) -> List[str]: """Return sorted tool names registered under a given toolset.""" return sorted( - entry.name for entry in self._snapshot_entries() - if entry.toolset == toolset + entry.name for entry in self._snapshot_entries() if entry.toolset == toolset ) def register_toolset_alias(self, alias: str, toolset: str) -> None: @@ -212,7 +251,9 @@ def register_toolset_alias(self, alias: str, toolset: str) -> None: if existing and existing != toolset: logger.warning( "Toolset alias collision: '%s' (%s) overwritten by %s", - alias, existing, toolset, + alias, + existing, + toolset, ) self._toolset_aliases[alias] = toolset self._generation += 1 @@ -251,14 +292,15 @@ def register( if existing and existing.toolset != toolset: # Allow MCP-to-MCP overwrites (legitimate: server refresh, # or two MCP servers with overlapping tool names). - both_mcp = ( - existing.toolset.startswith("mcp-") - and toolset.startswith("mcp-") + both_mcp = existing.toolset.startswith("mcp-") and toolset.startswith( + "mcp-" ) if both_mcp: logger.debug( "Tool '%s': MCP toolset '%s' overwriting MCP toolset '%s'", - name, toolset, existing.toolset, + name, + toolset, + existing.toolset, ) else: # Reject shadowing — prevent plugins/MCP from overwriting @@ -267,7 +309,9 @@ def register( "Tool registration REJECTED: '%s' (toolset '%s') would " "shadow existing tool from toolset '%s'. Deregister the " "existing tool first if this is intentional.", - name, toolset, existing.toolset, + name, + toolset, + existing.toolset, ) return self._tools[name] = ToolEntry( @@ -337,6 +381,13 @@ def get_definitions(self, tool_names: Set[str], quiet: bool = False) -> List[dic for name in sorted(tool_names): entry = entries_by_name.get(name) if not entry: + if not quiet: + logger.warning( + "Tool '%s' requested but not found in registry. " + "Check tool name spelling or run 'hermes tools list' " + "to see available tools.", + name, + ) continue if entry.check_fn: if entry.check_fn not in check_results: @@ -361,7 +412,8 @@ def get_definitions(self, tool_names: Set[str], quiet: bool = False) -> List[dic logger.warning( "dynamic_schema_overrides for tool %s raised %s; " "using static schema", - name, exc, + name, + exc, ) result.append({"type": "function", "function": schema_with_name}) return result @@ -379,21 +431,30 @@ def dispatch(self, name: str, args: dict, **kwargs) -> str: """ entry = self.get_entry(name) if not entry: - return json.dumps({"error": f"Unknown tool: {name}"}) + return json.dumps({ + "error": f"Unknown tool: '{name}'. " + f"Run 'hermes tools list' to see available tools.", + }) try: if entry.is_async: from model_tools import _run_async + return _run_async(entry.handler(args, **kwargs)) return entry.handler(args, **kwargs) except Exception as e: logger.exception("Tool %s dispatch error: %s", name, e) - return json.dumps({"error": f"Tool execution failed: {type(e).__name__}: {e}"}) + return json.dumps({ + "error": f"Tool execution failed: {type(e).__name__}: {e}. " + f"Check ~/.hermes/logs/errors.log for full traceback.", + }) # ------------------------------------------------------------------ # Query helpers (replace redundant dicts in model_tools.py) # ------------------------------------------------------------------ - def get_max_result_size(self, name: str, default: int | float | None = None) -> int | float: + def get_max_result_size( + self, name: str, default: int | float | None = None + ) -> int | float: """Return per-tool max result size, or *default* (or global default).""" entry = self.get_entry(name) if entry and entry.max_result_size_chars is not None: @@ -401,6 +462,7 @@ def get_max_result_size(self, name: str, default: int | float | None = None) -> if default is not None: return default from tools.budget_config import DEFAULT_RESULT_SIZE_CHARS + return DEFAULT_RESULT_SIZE_CHARS def get_all_tool_names(self) -> List[str]: @@ -424,7 +486,7 @@ def get_toolset_for_tool(self, name: str) -> Optional[str]: def get_emoji(self, name: str, default: str = "⚡") -> str: """Return the emoji for a tool, or *default* if unset.""" entry = self.get_entry(name) - return (entry.emoji if entry and entry.emoji else default) + return entry.emoji if entry and entry.emoji else default def get_tool_to_toolset_map(self) -> Dict[str, str]: """Return ``{tool_name: toolset_name}`` for every registered tool.""" @@ -438,7 +500,14 @@ def is_toolset_available(self, toolset: str) -> bool: """ with self._lock: check = self._toolset_checks.get(toolset) - return self._evaluate_toolset_check(toolset, check) + available = self._evaluate_toolset_check(toolset, check) + if not available: + logger.info( + "Toolset '%s' is unavailable. " + "Set required env vars or install missing dependencies.", + toolset, + ) + return available def check_toolset_requirements(self) -> Dict[str, bool]: """Return ``{toolset: available_bool}`` for every toolset.""" @@ -456,14 +525,18 @@ def get_available_toolsets(self) -> Dict[str, dict]: for entry in entries: ts = entry.toolset if ts not in toolsets: + is_available = self._evaluate_toolset_check(ts, toolset_checks.get(ts)) toolsets[ts] = { - "available": self._evaluate_toolset_check( - ts, toolset_checks.get(ts) - ), + "available": is_available, "tools": [], "description": "", "requirements": [], } + if not is_available: + toolsets[ts]["reason"] = ( + "unavailable - missing deps or env vars. " + "Run 'hermes tools status' for details." + ) toolsets[ts]["tools"].append(entry.name) if entry.requires_env: for env in entry.requires_env: @@ -506,10 +579,17 @@ def check_tool_availability(self, quiet: bool = False): if self._evaluate_toolset_check(ts, toolset_checks.get(ts)): available.append(ts) else: + env_vars = entry.requires_env or [] + reason_parts = [] + if env_vars: + reason_parts.append(f"missing env vars: {', '.join(env_vars)}") + if not reason_parts: + reason_parts.append("check function failed (no env vars defined)") unavailable.append({ "name": ts, - "env_vars": entry.requires_env, + "env_vars": env_vars, "tools": [e.name for e in entries if e.toolset == ts], + "reason": "; ".join(reason_parts), }) return available, unavailable From 946b9839aeee0026b0b776b2005fc5e5c1a39f9b Mon Sep 17 00:00:00 2001 From: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Date: Tue, 12 May 2026 23:54:15 +0100 Subject: [PATCH 2/4] fix(update): check upstream in fork early-return path --- hermes_cli/main.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7a30a57ca77f..3b219a905927 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7490,6 +7490,21 @@ def _cmd_update_impl(args, gateway_mode: bool): text=True, check=False, ) + # Fork: check upstream even when origin has no new commits. + # Without this, forks that track upstream via a separate remote + # always show "Already up to date" because origin (fork) is + # behind upstream but the early return never reaches + # _sync_with_upstream_if_needed at the end of the function. + if is_fork and branch == "main": + _sync_with_upstream_if_needed(git_cmd, PROJECT_ROOT) + # Push to origin so the fork stays in sync with upstream. + subprocess.run( + git_cmd + ["push", "origin", branch], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) print("✓ Already up to date!") return From 1e9cc617df4c46e729d448f39a62f8318c63db3a Mon Sep 17 00:00:00 2001 From: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Date: Wed, 13 May 2026 00:41:07 +0100 Subject: [PATCH 3/4] fix(update): allow merge when fork has local commits _sync_with_upstream_if_needed previously skipped entirely when origin had any commits not on upstream. now uses regular merge (not ff-only) when both sides have commits, preserving local patches while pulling upstream. --- hermes_cli/main.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3b219a905927..944a4f3cb142 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6464,31 +6464,28 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: print(" ✗ Could not compare branches. Skipping upstream sync.") return - # If origin/main has commits not on upstream, don't trample - if origin_ahead > 0: - print() - print(f"ℹ Your fork has {origin_ahead} commit(s) not on upstream.") - print(" Skipping upstream sync to preserve your changes.") - print(" If you want to merge upstream changes, run:") - print(" git pull upstream main") - return - # If upstream is not ahead, fork is up to date if upstream_ahead == 0: print(" ✓ Fork is up to date with upstream") return - # origin/main is strictly behind upstream/main (can fast-forward) + # origin/main has upstream commits to pull. + # If origin also has local commits (e.g. patches), use a regular merge + # instead of fast-forward-only so both histories are preserved. print() print(f"→ Fork is {upstream_ahead} commit(s) behind upstream") + if origin_ahead > 0: + print(f" (fork also has {origin_ahead} local commit(s) — merging)") print("→ Pulling from upstream...") + pull_args = ( + git_cmd + ["pull", "--ff-only", "upstream", "main"] + if origin_ahead == 0 + else git_cmd + ["pull", "upstream", "main"] + ) + try: - subprocess.run( - git_cmd + ["pull", "--ff-only", "upstream", "main"], - cwd=cwd, - check=True, - ) + subprocess.run(pull_args, cwd=cwd, check=True) except subprocess.CalledProcessError: print( " ✗ Failed to pull from upstream. You may need to resolve conflicts manually." From 0253a6eb29265241542f94499e952010662e4f31 Mon Sep 17 00:00:00 2001 From: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Date: Fri, 22 May 2026 11:46:40 +0100 Subject: [PATCH 4/4] fix: add missing aux model slots to model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit triage_specifier, kanban_decomposer, profile_describer exist in DEFAULT_CONFIG auxiliary section but weren't in _AUX_TASK_SLOTS, _AUX_TASKS, or the dashboard AUX_TASKS array — so users couldn't configure them through hermes model or the web dashboard. 9â\x86\x9212 aux slots across all three UI surfaces. --- hermes_cli/main.py | 3 +++ hermes_cli/web_server.py | 3 +++ web/src/pages/ModelsPage.tsx | 3 +++ 3 files changed, 9 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2cb6f43db8e6..7d4661ec5696 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2045,6 +2045,9 @@ def _clear_stale_openai_base_url(): ("mcp", "MCP", "MCP tool reasoning"), ("title_generation", "Title generation", "session titles"), ("skills_hub", "Skills hub", "skills search/install"), + ("triage_specifier", "Triage specifier", "kanban spec fleshing"), + ("kanban_decomposer", "Kanban decomposer", "task decomposition"), + ("profile_describer", "Profile describer", "auto profile descriptions"), ("curator", "Curator", "skill-usage review pass"), ] diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fb5f7ca12d33..3f7ab892471f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -980,6 +980,9 @@ def get_model_info(): "approval", "mcp", "title_generation", + "triage_specifier", + "kanban_decomposer", + "profile_describer", "curator", ) diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 01c239d7034f..e20b8629cb9d 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -49,6 +49,9 @@ const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [ { key: "approval", label: "Approval", hint: "Smart auto-approve" }, { key: "mcp", label: "MCP", hint: "MCP tool routing" }, { key: "title_generation", label: "Title Gen", hint: "Session titles" }, + { key: "triage_specifier", label: "Triage Specifier", hint: "Kanban spec fleshing" }, + { key: "kanban_decomposer", label: "Kanban Decomposer", hint: "Task decomposition" }, + { key: "profile_describer", label: "Profile Describer", hint: "Auto profile descriptions" }, { key: "curator", label: "Curator", hint: "Skill-usage review" }, ] as const;