Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]

Expand Down Expand Up @@ -6464,31 +6467,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."
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,9 @@ def get_model_info():
"approval",
"mcp",
"title_generation",
"triage_specifier",
"kanban_decomposer",
"profile_describer",
"curator",
)

Expand Down
138 changes: 109 additions & 29 deletions tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -70,22 +72,47 @@ 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


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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -379,28 +431,38 @@ 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:
return entry.max_result_size_chars
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]:
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions web/src/pages/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down