From 94023e6a85c42e90a3bf8e16a9e74ce6916794f2 Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Mon, 9 Mar 2026 23:13:39 +0300 Subject: [PATCH 01/12] feat: conditional skill activation based on tool availability Skills can now declare fallback_for_toolsets, fallback_for_tools, requires_toolsets, and requires_tools in their SKILL.md frontmatter. The system prompt builder filters skills automatically based on which tools are available in the current session. - Add _read_skill_conditions() to parse conditional frontmatter fields - Add _skill_should_show() to evaluate conditions against available tools - Update build_skills_system_prompt() to accept and apply tool availability - Pass valid_tool_names and available toolsets from run_agent.py - Backward compatible: skills without conditions always show; calling build_skills_system_prompt() with no args preserves existing behavior Closes #539 --- agent/prompt_builder.py | 57 +++++++++- run_agent.py | 9 +- tests/agent/test_prompt_builder.py | 176 +++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 2 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 0582d63d3..2824faa59 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -179,7 +179,58 @@ def _skill_is_platform_compatible(skill_file: Path) -> bool: return True # Err on the side of showing the skill -def build_skills_system_prompt() -> str: +def _read_skill_conditions(skill_file: Path) -> dict: + """Extract conditional activation fields from SKILL.md frontmatter.""" + try: + from tools.skills_tool import _parse_frontmatter + raw = skill_file.read_text(encoding="utf-8")[:2000] + frontmatter, _ = _parse_frontmatter(raw) + hermes = frontmatter.get("metadata", {}).get("hermes", {}) + return { + "fallback_for_toolsets": hermes.get("fallback_for_toolsets", []), + "requires_toolsets": hermes.get("requires_toolsets", []), + "fallback_for_tools": hermes.get("fallback_for_tools", []), + "requires_tools": hermes.get("requires_tools", []), + } + except Exception: + return {} + + +def _skill_should_show( + conditions: dict, + available_tools: "set[str] | None", + available_toolsets: "set[str] | None", +) -> bool: + """Return False if the skill's conditional activation rules exclude it.""" + if available_tools is None and available_toolsets is None: + return True # No filtering info — show everything (backward compat) + + at = available_tools or set() + ats = available_toolsets or set() + + # fallback_for: hide when the primary tool/toolset IS available + for ts in conditions.get("fallback_for_toolsets", []): + if ts in ats: + return False + for t in conditions.get("fallback_for_tools", []): + if t in at: + return False + + # requires: hide when a required tool/toolset is NOT available + for ts in conditions.get("requires_toolsets", []): + if ts not in ats: + return False + for t in conditions.get("requires_tools", []): + if t not in at: + return False + + return True + + +def build_skills_system_prompt( + available_tools: "set[str] | None" = None, + available_toolsets: "set[str] | None" = None, +) -> str: """Build a compact skill index for the system prompt. Scans ~/.hermes/skills/ for SKILL.md files grouped by category. @@ -202,6 +253,10 @@ def build_skills_system_prompt() -> str: # Skip skills incompatible with the current OS platform if not _skill_is_platform_compatible(skill_file): continue + # Skip skills whose conditional activation rules exclude them + conditions = _read_skill_conditions(skill_file) + if not _skill_should_show(conditions, available_tools, available_toolsets): + continue rel_path = skill_file.relative_to(skills_dir) parts = rel_path.parts if len(parts) >= 2: diff --git a/run_agent.py b/run_agent.py index c1f2623c8..80937b34d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1410,7 +1410,14 @@ class AIAgent: prompt_parts.append(user_block) has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) - skills_prompt = build_skills_system_prompt() if has_skills_tools else "" + if has_skills_tools: + avail_toolsets = {ts for ts, avail in check_toolset_requirements().items() if avail} + skills_prompt = build_skills_system_prompt( + available_tools=self.valid_tool_names, + available_toolsets=avail_toolsets, + ) + else: + skills_prompt = "" if skills_prompt: prompt_parts.append(skills_prompt) diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index a35983b5f..972f3f753 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -8,6 +8,8 @@ from agent.prompt_builder import ( _scan_context_content, _truncate_content, _read_skill_description, + _read_skill_conditions, + _skill_should_show, build_skills_system_prompt, build_context_files_prompt, CONTEXT_FILE_MAX_CHARS, @@ -277,3 +279,177 @@ class TestPromptBuilderConstants: assert "telegram" in PLATFORM_HINTS assert "discord" in PLATFORM_HINTS assert "cli" in PLATFORM_HINTS + + +# ========================================================================= +# Conditional skill activation +# ========================================================================= + +class TestReadSkillConditions: + def test_no_conditions_returns_empty_lists(self, tmp_path): + skill_file = tmp_path / "SKILL.md" + skill_file.write_text("---\nname: test\ndescription: A skill\n---\n") + conditions = _read_skill_conditions(skill_file) + assert conditions["fallback_for_toolsets"] == [] + assert conditions["requires_toolsets"] == [] + assert conditions["fallback_for_tools"] == [] + assert conditions["requires_tools"] == [] + + def test_reads_fallback_for_toolsets(self, tmp_path): + skill_file = tmp_path / "SKILL.md" + skill_file.write_text( + "---\nname: ddg\ndescription: DuckDuckGo\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n" + ) + conditions = _read_skill_conditions(skill_file) + assert conditions["fallback_for_toolsets"] == ["web"] + + def test_reads_requires_toolsets(self, tmp_path): + skill_file = tmp_path / "SKILL.md" + skill_file.write_text( + "---\nname: openhue\ndescription: Hue lights\nmetadata:\n hermes:\n requires_toolsets: [terminal]\n---\n" + ) + conditions = _read_skill_conditions(skill_file) + assert conditions["requires_toolsets"] == ["terminal"] + + def test_reads_multiple_conditions(self, tmp_path): + skill_file = tmp_path / "SKILL.md" + skill_file.write_text( + "---\nname: test\ndescription: Test\nmetadata:\n hermes:\n fallback_for_toolsets: [browser]\n requires_tools: [terminal]\n---\n" + ) + conditions = _read_skill_conditions(skill_file) + assert conditions["fallback_for_toolsets"] == ["browser"] + assert conditions["requires_tools"] == ["terminal"] + + def test_missing_file_returns_empty(self, tmp_path): + conditions = _read_skill_conditions(tmp_path / "missing.md") + assert conditions == {} + + +class TestSkillShouldShow: + def test_no_filter_info_always_shows(self): + assert _skill_should_show({}, None, None) is True + + def test_empty_conditions_always_shows(self): + assert _skill_should_show( + {"fallback_for_toolsets": [], "requires_toolsets": [], + "fallback_for_tools": [], "requires_tools": []}, + {"web_search"}, {"web"} + ) is True + + def test_fallback_hidden_when_toolset_available(self): + conditions = {"fallback_for_toolsets": ["web"], "requires_toolsets": [], + "fallback_for_tools": [], "requires_tools": []} + assert _skill_should_show(conditions, set(), {"web"}) is False + + def test_fallback_shown_when_toolset_unavailable(self): + conditions = {"fallback_for_toolsets": ["web"], "requires_toolsets": [], + "fallback_for_tools": [], "requires_tools": []} + assert _skill_should_show(conditions, set(), set()) is True + + def test_requires_shown_when_toolset_available(self): + conditions = {"fallback_for_toolsets": [], "requires_toolsets": ["terminal"], + "fallback_for_tools": [], "requires_tools": []} + assert _skill_should_show(conditions, set(), {"terminal"}) is True + + def test_requires_hidden_when_toolset_missing(self): + conditions = {"fallback_for_toolsets": [], "requires_toolsets": ["terminal"], + "fallback_for_tools": [], "requires_tools": []} + assert _skill_should_show(conditions, set(), set()) is False + + def test_fallback_for_tools_hidden_when_tool_available(self): + conditions = {"fallback_for_toolsets": [], "requires_toolsets": [], + "fallback_for_tools": ["web_search"], "requires_tools": []} + assert _skill_should_show(conditions, {"web_search"}, set()) is False + + def test_fallback_for_tools_shown_when_tool_missing(self): + conditions = {"fallback_for_toolsets": [], "requires_toolsets": [], + "fallback_for_tools": ["web_search"], "requires_tools": []} + assert _skill_should_show(conditions, set(), set()) is True + + def test_requires_tools_hidden_when_tool_missing(self): + conditions = {"fallback_for_toolsets": [], "requires_toolsets": [], + "fallback_for_tools": [], "requires_tools": ["terminal"]} + assert _skill_should_show(conditions, set(), set()) is False + + def test_requires_tools_shown_when_tool_available(self): + conditions = {"fallback_for_toolsets": [], "requires_toolsets": [], + "fallback_for_tools": [], "requires_tools": ["terminal"]} + assert _skill_should_show(conditions, {"terminal"}, set()) is True + + +class TestBuildSkillsSystemPromptConditional: + def test_fallback_skill_hidden_when_primary_available(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skill_dir = tmp_path / "skills" / "search" / "duckduckgo" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n" + ) + result = build_skills_system_prompt( + available_tools=set(), + available_toolsets={"web"}, + ) + assert "duckduckgo" not in result + + def test_fallback_skill_shown_when_primary_unavailable(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skill_dir = tmp_path / "skills" / "search" / "duckduckgo" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n" + ) + result = build_skills_system_prompt( + available_tools=set(), + available_toolsets=set(), + ) + assert "duckduckgo" in result + + def test_requires_skill_hidden_when_toolset_missing(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skill_dir = tmp_path / "skills" / "iot" / "openhue" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: openhue\ndescription: Hue lights\nmetadata:\n hermes:\n requires_toolsets: [terminal]\n---\n" + ) + result = build_skills_system_prompt( + available_tools=set(), + available_toolsets=set(), + ) + assert "openhue" not in result + + def test_requires_skill_shown_when_toolset_available(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skill_dir = tmp_path / "skills" / "iot" / "openhue" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: openhue\ndescription: Hue lights\nmetadata:\n hermes:\n requires_toolsets: [terminal]\n---\n" + ) + result = build_skills_system_prompt( + available_tools=set(), + available_toolsets={"terminal"}, + ) + assert "openhue" in result + + def test_unconditional_skill_always_shown(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skill_dir = tmp_path / "skills" / "general" / "notes" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: notes\ndescription: Take notes\n---\n" + ) + result = build_skills_system_prompt( + available_tools=set(), + available_toolsets=set(), + ) + assert "notes" in result + + def test_no_args_shows_all_skills(self, monkeypatch, tmp_path): + """Backward compat: calling with no args shows everything.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skill_dir = tmp_path / "skills" / "search" / "duckduckgo" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n hermes:\n fallback_for_toolsets: [web]\n---\n" + ) + result = build_skills_system_prompt() + assert "duckduckgo" in result From 4a8f23eddff6fe0dbe01c4b0ee37efdb06e31f82 Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:27:59 +0300 Subject: [PATCH 02/12] fix: correctly track failed MCP server connections in discovery _discover_one() caught all exceptions and returned [], making asyncio.gather(return_exceptions=True) redundant. The isinstance(result, Exception) branch in _discover_all() was dead code, so failed_count was always 0. This caused: - No summary printed when all servers fail (silent failure) - ok_servers always equaling total_servers (misleading count) - Unused variables transport_desc and transport_type Fix: let exceptions propagate to gather() so failed_count increments correctly. Move per-server failure logging to _discover_all(). Remove dead variables. --- tests/tools/test_mcp_tool.py | 124 +++++++++++++++++++++++++++++++++++ tools/mcp_tool.py | 20 ++---- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 446f80d3e..0f7fc18a5 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -2326,3 +2326,127 @@ class TestMCPServerTaskSamplingIntegration: kwargs = server._sampling.session_kwargs() assert "sampling_callback" in kwargs assert "sampling_capabilities" in kwargs + + +# --------------------------------------------------------------------------- +# Discovery failed_count tracking +# --------------------------------------------------------------------------- + +class TestDiscoveryFailedCount: + """Verify discover_mcp_tools() correctly tracks failed server connections.""" + + def test_failed_server_increments_failed_count(self): + """When _discover_and_register_server raises, failed_count increments.""" + from tools.mcp_tool import discover_mcp_tools, _servers, _ensure_mcp_loop + + fake_config = { + "good_server": {"command": "npx", "args": ["good"]}, + "bad_server": {"command": "npx", "args": ["bad"]}, + } + + async def fake_register(name, cfg): + if name == "bad_server": + raise ConnectionError("Connection refused") + # Simulate successful registration + from tools.mcp_tool import MCPServerTask + server = MCPServerTask(name) + server.session = MagicMock() + server._tools = [_make_mcp_tool("tool_a")] + _servers[name] = server + return [f"mcp_{name}_tool_a"] + + with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ + patch("tools.mcp_tool._discover_and_register_server", side_effect=fake_register), \ + patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_good_server_tool_a"]): + _ensure_mcp_loop() + + # Capture the logger to verify failed_count in summary + with patch("tools.mcp_tool.logger") as mock_logger: + discover_mcp_tools() + + # Find the summary info call + info_calls = [ + str(call) + for call in mock_logger.info.call_args_list + if "failed" in str(call).lower() or "MCP:" in str(call) + ] + # The summary should mention the failure + assert any("1 failed" in str(c) for c in info_calls), ( + f"Summary should report 1 failed server, got: {info_calls}" + ) + + _servers.pop("good_server", None) + _servers.pop("bad_server", None) + + def test_all_servers_fail_still_prints_summary(self): + """When all servers fail, a summary with failure count is still printed.""" + from tools.mcp_tool import discover_mcp_tools, _servers, _ensure_mcp_loop + + fake_config = { + "srv1": {"command": "npx", "args": ["a"]}, + "srv2": {"command": "npx", "args": ["b"]}, + } + + async def always_fail(name, cfg): + raise ConnectionError(f"Server {name} refused") + + with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ + patch("tools.mcp_tool._discover_and_register_server", side_effect=always_fail), \ + patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch("tools.mcp_tool._existing_tool_names", return_value=[]): + _ensure_mcp_loop() + + with patch("tools.mcp_tool.logger") as mock_logger: + discover_mcp_tools() + + # Summary must be printed even when all servers fail + info_calls = [str(call) for call in mock_logger.info.call_args_list] + assert any("2 failed" in str(c) for c in info_calls), ( + f"Summary should report 2 failed servers, got: {info_calls}" + ) + + _servers.pop("srv1", None) + _servers.pop("srv2", None) + + def test_ok_servers_excludes_failures(self): + """ok_servers count correctly excludes failed servers.""" + from tools.mcp_tool import discover_mcp_tools, _servers, _ensure_mcp_loop + + fake_config = { + "ok1": {"command": "npx", "args": ["ok1"]}, + "ok2": {"command": "npx", "args": ["ok2"]}, + "fail1": {"command": "npx", "args": ["fail"]}, + } + + async def selective_register(name, cfg): + if name == "fail1": + raise ConnectionError("Refused") + from tools.mcp_tool import MCPServerTask + server = MCPServerTask(name) + server.session = MagicMock() + server._tools = [_make_mcp_tool("t")] + _servers[name] = server + return [f"mcp_{name}_t"] + + with patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ + patch("tools.mcp_tool._discover_and_register_server", side_effect=selective_register), \ + patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch("tools.mcp_tool._existing_tool_names", return_value=["mcp_ok1_t", "mcp_ok2_t"]): + _ensure_mcp_loop() + + with patch("tools.mcp_tool.logger") as mock_logger: + discover_mcp_tools() + + info_calls = [str(call) for call in mock_logger.info.call_args_list] + # Should say "2 server(s)" not "3 server(s)" + assert any("2 server" in str(c) for c in info_calls), ( + f"Summary should report 2 ok servers, got: {info_calls}" + ) + assert any("1 failed" in str(c) for c in info_calls), ( + f"Summary should report 1 failed, got: {info_calls}" + ) + + _servers.pop("ok1", None) + _servers.pop("ok2", None) + _servers.pop("fail1", None) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index b0fc35f7f..94495430b 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1331,29 +1331,23 @@ def discover_mcp_tools() -> List[str]: async def _discover_one(name: str, cfg: dict) -> List[str]: """Connect to a single server and return its registered tool names.""" - transport_desc = cfg.get("url", f'{cfg.get("command", "?")} {" ".join(cfg.get("args", [])[:2])}') - try: - registered = await _discover_and_register_server(name, cfg) - transport_type = "HTTP" if "url" in cfg else "stdio" - return registered - except Exception as exc: - logger.warning( - "Failed to connect to MCP server '%s': %s", - name, exc, - ) - return [] + return await _discover_and_register_server(name, cfg) async def _discover_all(): nonlocal failed_count + server_names = list(new_servers.keys()) # Connect to all servers in PARALLEL results = await asyncio.gather( *(_discover_one(name, cfg) for name, cfg in new_servers.items()), return_exceptions=True, ) - for result in results: + for name, result in zip(server_names, results): if isinstance(result, Exception): failed_count += 1 - logger.warning("MCP discovery error: %s", result) + logger.warning( + "Failed to connect to MCP server '%s': %s", + name, result, + ) elif isinstance(result, list): all_tools.extend(result) else: From 605ba4adea51af2580f1ab94fd6372e873c108e7 Mon Sep 17 00:00:00 2001 From: 0xNyk <0xNyk@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:38:24 -0700 Subject: [PATCH 03/12] fix(cron): interpret naive timestamps as local time in due-job checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy cron job rows may store next_run_at without timezone info. _ensure_aware() previously stamped the Hermes-configured tz directly via replace(tzinfo=...), which shifts absolute time when system-local tz differs from Hermes tz — causing overdue jobs to appear not due. Now: naive datetimes are interpreted as system-local wall time first, then converted to Hermes tz. Aware datetimes are normalized to Hermes tz for consistency. Cherry-picked from PR #807, rebased onto current main. Fixes #806 Co-authored-by: 0xNyk <0xNyk@users.noreply.github.com> --- cron/jobs.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 0c062cfea..6cbb168f0 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -168,16 +168,22 @@ def parse_schedule(schedule: str) -> Dict[str, Any]: def _ensure_aware(dt: datetime) -> datetime: - """Make a naive datetime tz-aware using the configured timezone. + """Return a timezone-aware datetime in Hermes configured timezone. - Handles backward compatibility: timestamps stored before timezone support - are naive (server-local). We assume they were in the same timezone as - the current configuration so comparisons work without crashing. + Backward compatibility: + - Older stored timestamps may be naive. + - Naive values are interpreted as *system-local wall time* (the timezone + `datetime.now()` used when they were created), then converted to the + configured Hermes timezone. + + This preserves relative ordering for legacy naive timestamps across + timezone changes and avoids false not-due results. """ + target_tz = _hermes_now().tzinfo if dt.tzinfo is None: - tz = _hermes_now().tzinfo - return dt.replace(tzinfo=tz) - return dt + local_tz = datetime.now().astimezone().tzinfo + return dt.replace(tzinfo=local_tz).astimezone(target_tz) + return dt.astimezone(target_tz) def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]: From a5ffa1278c987dda5e551fb8772d5e75c67d3869 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Wed, 11 Mar 2026 08:42:04 -0700 Subject: [PATCH 04/12] test(cron): add regression tests for _ensure_aware timezone conversion Three new tests for the naive timestamp fix (PR #807): - test_ensure_aware_naive_preserves_absolute_time: verifies UTC equivalent is preserved when interpreting naive datetimes as system-local time - test_ensure_aware_normalizes_aware_to_hermes_tz: verifies already-aware datetimes are normalized to Hermes tz without shifting the instant - test_ensure_aware_due_job_not_skipped_when_system_ahead: end-to-end regression test for the original bug scenario --- tests/test_timezone.py | 79 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_timezone.py b/tests/test_timezone.py index 3d657989e..9902817d8 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -249,6 +249,85 @@ class TestCronTimezone: due = get_due_jobs() assert len(due) == 1 + def test_ensure_aware_naive_preserves_absolute_time(self): + """_ensure_aware must preserve the absolute instant for naive datetimes. + + Regression: the old code used replace(tzinfo=hermes_tz) which shifted + absolute time when system-local tz != Hermes tz. The fix interprets + naive values as system-local wall time, then converts. + """ + from cron.jobs import _ensure_aware + + os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata" + hermes_time.reset_cache() + + # Create a naive datetime — will be interpreted as system-local time + naive_dt = datetime(2026, 3, 11, 12, 0, 0) + + result = _ensure_aware(naive_dt) + + # The result should be in Kolkata tz + assert result.tzinfo is not None + + # The UTC equivalent must match what we'd get by correctly interpreting + # the naive dt as system-local time first, then converting + system_tz = datetime.now().astimezone().tzinfo + expected_utc = naive_dt.replace(tzinfo=system_tz).astimezone(timezone.utc) + actual_utc = result.astimezone(timezone.utc) + assert actual_utc == expected_utc, ( + f"Absolute time shifted: expected {expected_utc}, got {actual_utc}" + ) + + def test_ensure_aware_normalizes_aware_to_hermes_tz(self): + """Already-aware datetimes should be normalized to Hermes tz.""" + from cron.jobs import _ensure_aware + + os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata" + hermes_time.reset_cache() + + # Create an aware datetime in UTC + utc_dt = datetime(2026, 3, 11, 15, 0, 0, tzinfo=timezone.utc) + result = _ensure_aware(utc_dt) + + # Must be in Hermes tz (Kolkata) but same absolute instant + kolkata = ZoneInfo("Asia/Kolkata") + assert result.utctimetuple()[:5] == (2026, 3, 11, 15, 0) + expected_local = utc_dt.astimezone(kolkata) + assert result == expected_local + + def test_ensure_aware_due_job_not_skipped_when_system_ahead(self, tmp_path, monkeypatch): + """Reproduce the actual bug: system tz ahead of Hermes tz caused + overdue jobs to appear as not-yet-due. + + Scenario: system is Asia/Kolkata (UTC+5:30), Hermes is UTC. + A naive timestamp from 5 minutes ago (local time) should still + be recognized as due after conversion. + """ + import cron.jobs as jobs_module + monkeypatch.setattr(jobs_module, "CRON_DIR", tmp_path / "cron") + monkeypatch.setattr(jobs_module, "JOBS_FILE", tmp_path / "cron" / "jobs.json") + monkeypatch.setattr(jobs_module, "OUTPUT_DIR", tmp_path / "cron" / "output") + + os.environ["HERMES_TIMEZONE"] = "UTC" + hermes_time.reset_cache() + + from cron.jobs import create_job, load_jobs, save_jobs, get_due_jobs + + job = create_job(prompt="Bug repro", schedule="every 1h") + jobs = load_jobs() + + # Simulate a naive timestamp that was written by datetime.now() on a + # system running in UTC+5:30 — 5 minutes in the past (local time) + naive_past = (datetime.now() - timedelta(minutes=5)).isoformat() + jobs[0]["next_run_at"] = naive_past + save_jobs(jobs) + + # Must be recognized as due regardless of tz mismatch + due = get_due_jobs() + assert len(due) == 1, ( + "Overdue job was skipped — _ensure_aware likely shifted absolute time" + ) + def test_create_job_stores_tz_aware_timestamps(self, tmp_path, monkeypatch): """New jobs store timezone-aware created_at and next_run_at.""" import cron.jobs as jobs_module From 82113f1f1edd133251c38618bc541dd9361454c1 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Wed, 11 Mar 2026 08:47:01 -0700 Subject: [PATCH 05/12] =?UTF-8?q?docs:=20conditional=20skill=20activation?= =?UTF-8?q?=20=E2=80=94=20tag=20duckduckgo-search=20as=20web=20fallback=20?= =?UTF-8?q?and=20add=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tag duckduckgo-search skill with fallback_for_toolsets: [web] so it auto-hides when Firecrawl is available and auto-shows when it isn't - Add 'Conditional Activation' section to CONTRIBUTING.md with full spec, semantics, and examples for all 4 frontmatter fields - Add 'Conditional Activation (Fallback Skills)' section to the user- facing skills docs with field reference table and practical example - Update SKILL.md format examples in both docs to show the new fields Follow-up to PR #785 (conditional skill activation feature). --- CONTRIBUTING.md | 44 ++++++++++++++++++++++ skills/research/duckduckgo-search/SKILL.md | 1 + website/docs/user-guide/features/skills.md | 26 +++++++++++++ 3 files changed, 71 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e66dbb3e9..60e8706bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -333,6 +333,8 @@ metadata: hermes: tags: [Category, Subcategory, Keywords] related_skills: [other-skill-name] + fallback_for_toolsets: [web] # Optional — show only when toolset is unavailable + requires_toolsets: [terminal] # Optional — show only when toolset is available --- # Skill Title @@ -367,6 +369,48 @@ platforms: [windows] # Windows only If the field is omitted or empty, the skill loads on all platforms (backward compatible). See `skills/apple/` for examples of macOS-only skills. +### Conditional skill activation + +Skills can declare conditions that control when they appear in the system prompt, based on which tools and toolsets are available in the current session. This is primarily used for **fallback skills** — alternatives that should only be shown when a primary tool is unavailable. + +Four fields are supported under `metadata.hermes`: + +```yaml +metadata: + hermes: + fallback_for_toolsets: [web] # Show ONLY when these toolsets are unavailable + requires_toolsets: [terminal] # Show ONLY when these toolsets are available + fallback_for_tools: [web_search] # Show ONLY when these specific tools are unavailable + requires_tools: [terminal] # Show ONLY when these specific tools are available +``` + +**Semantics:** +- `fallback_for_*`: The skill is a backup. It is **hidden** when the listed tools/toolsets are available, and **shown** when they are unavailable. Use this for free alternatives to premium tools. +- `requires_*`: The skill needs certain tools to function. It is **hidden** when the listed tools/toolsets are unavailable. Use this for skills that depend on specific capabilities (e.g., a skill that only makes sense with terminal access). +- If both are specified, both conditions must be satisfied for the skill to appear. +- If neither is specified, the skill is always shown (backward compatible). + +**Examples:** + +```yaml +# DuckDuckGo search — shown when Firecrawl (web toolset) is unavailable +metadata: + hermes: + fallback_for_toolsets: [web] + +# Smart home skill — only useful when terminal is available +metadata: + hermes: + requires_toolsets: [terminal] + +# Local browser fallback — shown when Browserbase is unavailable +metadata: + hermes: + fallback_for_toolsets: [browser] +``` + +The filtering happens at prompt build time in `agent/prompt_builder.py`. The `build_skills_system_prompt()` function receives the set of available tools and toolsets from the agent and uses `_skill_should_show()` to evaluate each skill's conditions. + ### Skill guidelines - **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`). diff --git a/skills/research/duckduckgo-search/SKILL.md b/skills/research/duckduckgo-search/SKILL.md index 6081581ef..afe7858a2 100644 --- a/skills/research/duckduckgo-search/SKILL.md +++ b/skills/research/duckduckgo-search/SKILL.md @@ -8,6 +8,7 @@ metadata: hermes: tags: [search, duckduckgo, web-search, free, fallback] related_skills: [arxiv] + fallback_for_toolsets: [web] --- # DuckDuckGo Search diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 8eb838d2c..8f02be20c 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -55,6 +55,8 @@ metadata: hermes: tags: [python, automation] category: devops + fallback_for_toolsets: [web] # Optional — conditional activation (see below) + requires_toolsets: [terminal] # Optional — conditional activation (see below) --- # Skill Title @@ -90,6 +92,30 @@ platforms: [macos, linux] # macOS and Linux When set, the skill is automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. If omitted, the skill loads on all platforms. +### Conditional Activation (Fallback Skills) + +Skills can automatically show or hide themselves based on which tools are available in the current session. This is most useful for **fallback skills** — free or local alternatives that should only appear when a premium tool is unavailable. + +```yaml +metadata: + hermes: + fallback_for_toolsets: [web] # Show ONLY when these toolsets are unavailable + requires_toolsets: [terminal] # Show ONLY when these toolsets are available + fallback_for_tools: [web_search] # Show ONLY when these specific tools are unavailable + requires_tools: [terminal] # Show ONLY when these specific tools are available +``` + +| Field | Behavior | +|-------|----------| +| `fallback_for_toolsets` | Skill is **hidden** when the listed toolsets are available. Shown when they're missing. | +| `fallback_for_tools` | Same, but checks individual tools instead of toolsets. | +| `requires_toolsets` | Skill is **hidden** when the listed toolsets are unavailable. Shown when they're present. | +| `requires_tools` | Same, but checks individual tools. | + +**Example:** The built-in `duckduckgo-search` skill uses `fallback_for_toolsets: [web]`. When you have `FIRECRAWL_API_KEY` set, the web toolset is available and the agent uses `web_search` — the DuckDuckGo skill stays hidden. If the API key is missing, the web toolset is unavailable and the DuckDuckGo skill automatically appears as a fallback. + +Skills without any conditional fields behave exactly as before — they're always shown. + ## Skill Directory Structure ``` From 66c0b719de612af9b947f3f883a704982a3aace0 Mon Sep 17 00:00:00 2001 From: Dev User Date: Wed, 11 Mar 2026 00:09:37 +0100 Subject: [PATCH 06/12] fix(gateway): pass model to temporary AIAgent instances Memory flush, /compress, and session hygiene create AIAgent without model=, falling back to the hardcoded default "anthropic/claude-opus-4.6". This fails with a 400 error when the active provider is openai-codex (Codex only accepts its own model names like gpt-5.1-codex-mini). Add _resolve_gateway_model() that mirrors the env/config resolution already used by _run_agent_sync, and wire it into all three temporary agent creation sites. Co-Authored-By: Claude Opus 4.6 --- gateway/run.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 96d43672f..7e07d06b6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -187,6 +187,30 @@ def _resolve_runtime_agent_kwargs() -> dict: } +def _resolve_gateway_model() -> str: + """Read model from env/config — mirrors the resolution in _run_agent_sync. + + Without this, temporary AIAgent instances (memory flush, /compress) fall + back to the hardcoded default ("anthropic/claude-opus-4.6") which fails + when the active provider is openai-codex. + """ + model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6" + try: + import yaml as _y + _cfg_path = _hermes_home / "config.yaml" + if _cfg_path.exists(): + with open(_cfg_path, encoding="utf-8") as _f: + _cfg = _y.safe_load(_f) or {} + _model_cfg = _cfg.get("model", {}) + if isinstance(_model_cfg, str): + model = _model_cfg + elif isinstance(_model_cfg, dict): + model = _model_cfg.get("default", model) + except Exception: + pass + return model + + class GatewayRunner: """ Main gateway controller. @@ -258,8 +282,14 @@ class GatewayRunner: if not runtime_kwargs.get("api_key"): return + # Resolve model from config — AIAgent's default is OpenRouter- + # formatted ("anthropic/claude-opus-4.6") which fails when the + # active provider is openai-codex. + model = _resolve_gateway_model() + tmp_agent = AIAgent( **runtime_kwargs, + model=model, max_iterations=8, quiet_mode=True, enabled_toolsets=["memory", "skills"], @@ -1106,6 +1136,7 @@ class GatewayRunner: if len(_hyg_msgs) >= 4: _hyg_agent = AIAgent( **_hyg_runtime, + model=_hyg_model, max_iterations=4, quiet_mode=True, enabled_toolsets=["memory"], @@ -2169,6 +2200,9 @@ class GatewayRunner: if not runtime_kwargs.get("api_key"): return "No provider configured -- cannot compress." + # Resolve model from config (same reason as memory flush above). + model = _resolve_gateway_model() + msgs = [ {"role": m.get("role"), "content": m.get("content")} for m in history @@ -2179,6 +2213,7 @@ class GatewayRunner: tmp_agent = AIAgent( **runtime_kwargs, + model=model, max_iterations=4, quiet_mode=True, enabled_toolsets=["memory"], From 3667138d05da6787ce7bb9e353fe8d74ecb36fd9 Mon Sep 17 00:00:00 2001 From: alireza78a Date: Wed, 11 Mar 2026 08:58:33 -0700 Subject: [PATCH 07/12] fix(config): atomic write for .env to prevent API key loss on crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save_env_value() used bare open('w') which truncates .env immediately. A crash or OOM kill between truncation and completed write silently wipes every credential in the file. Write now goes to a temp file first, then os.replace() swaps it atomically. Either the old .env exists or the new one does — never a truncated half-write. Same pattern used in cron/jobs.py. Cherry-picked from PR #842 by alireza78a, rebased onto current main with conflict resolution (_secure_file refactor). Co-authored-by: alireza78a --- hermes_cli/config.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 758118492..f2b5d42c1 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -17,6 +17,7 @@ import platform import stat import subprocess import sys +import tempfile from pathlib import Path from typing import Dict, Any, Optional, List, Tuple @@ -958,8 +959,19 @@ def save_env_value(key: str, value: str): lines[-1] += "\n" lines.append(f"{key}={value}\n") - with open(env_path, 'w', **write_kw) as f: - f.writelines(lines) + fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_') + try: + with os.fdopen(fd, 'w', **write_kw) as f: + f.writelines(lines) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, env_path) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise _secure_file(env_path) # Restrict .env permissions to owner-only (contains API keys) From 01bec407245f2004bea0a0dc3ad35e2dfc97a502 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Wed, 11 Mar 2026 08:59:09 -0700 Subject: [PATCH 08/12] refactor(gateway): consolidate model resolution via _resolve_gateway_model() Replace two inline copies of the env/config model resolution pattern (in _run_agent_sync and _run_agent) with the _resolve_gateway_model() helper introduced in PR #830. Left untouched: - Session hygiene block: different default (sonnet vs opus) + reads compression config from the same YAML load - /model command: also reads provider from same config block --- gateway/run.py | 33 +++------------------------------ 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 7e07d06b6..3c2abd834 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2029,21 +2029,8 @@ class GatewayRunner: ) return - # Read model from config (same as _run_agent) - model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6" - try: - import yaml as _y - _cfg_path = _hermes_home / "config.yaml" - if _cfg_path.exists(): - with open(_cfg_path, encoding="utf-8") as _f: - _cfg = _y.safe_load(_f) or {} - _model_cfg = _cfg.get("model", {}) - if isinstance(_model_cfg, str): - model = _model_cfg - elif isinstance(_model_cfg, dict): - model = _model_cfg.get("default", model) - except Exception: - pass + # Read model from config via shared helper + model = _resolve_gateway_model() # Determine toolset (same logic as _run_agent) default_toolset_map = { @@ -3128,21 +3115,7 @@ class GatewayRunner: except Exception: pass - model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6" - - try: - import yaml as _y - _cfg_path = _hermes_home / "config.yaml" - if _cfg_path.exists(): - with open(_cfg_path, encoding="utf-8") as _f: - _cfg = _y.safe_load(_f) or {} - _model_cfg = _cfg.get("model", {}) - if isinstance(_model_cfg, str): - model = _model_cfg - elif isinstance(_model_cfg, dict): - model = _model_cfg.get("default", model) - except Exception: - pass + model = _resolve_gateway_model() try: runtime_kwargs = _resolve_runtime_agent_kwargs() From 91101065bb37cd170acd6bed0ab9e05e524e41a6 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Wed, 11 Mar 2026 08:59:59 -0700 Subject: [PATCH 09/12] fix: improve git error logging in checkpoint manager - Log command, return code, and stderr on non-zero exit - Add exc_info=True to timeout, FileNotFoundError, and catch-all handlers - Add debug field to restore() error responses with raw git output - Keeps user-facing error messages clean while preserving detail for debugging Inspired by PR #843 (aydnOktay). --- tools/checkpoint_manager.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 57671c54d..16ef69ead 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -95,21 +95,34 @@ def _run_git( ) -> tuple: """Run a git command against the shadow repo. Returns (ok, stdout, stderr).""" env = _git_env(shadow_repo, working_dir) + cmd = ["git"] + list(args) try: result = subprocess.run( - ["git"] + args, + cmd, capture_output=True, text=True, timeout=timeout, env=env, cwd=str(Path(working_dir).resolve()), ) - return result.returncode == 0, result.stdout.strip(), result.stderr.strip() + ok = result.returncode == 0 + stdout = result.stdout.strip() + stderr = result.stderr.strip() + if not ok: + logger.error( + "Git command failed: %s (rc=%d) stderr=%s", + " ".join(cmd), result.returncode, stderr, + ) + return ok, stdout, stderr except subprocess.TimeoutExpired: - return False, "", f"git timed out after {timeout}s: git {' '.join(args)}" + msg = f"git timed out after {timeout}s: {' '.join(cmd)}" + logger.error(msg, exc_info=True) + return False, "", msg except FileNotFoundError: + logger.error("Git executable not found: %s", " ".join(cmd), exc_info=True) return False, "", "git not found" except Exception as exc: + logger.error("Unexpected git error running %s: %s", " ".join(cmd), exc, exc_info=True) return False, "", str(exc) @@ -287,7 +300,7 @@ class CheckpointManager: ["cat-file", "-t", commit_hash], shadow, abs_dir, ) if not ok: - return {"success": False, "error": f"Checkpoint '{commit_hash}' not found"} + return {"success": False, "error": f"Checkpoint '{commit_hash}' not found", "debug": err or None} # Take a checkpoint of current state before restoring (so you can undo the undo) self._take(abs_dir, f"pre-rollback snapshot (restoring to {commit_hash[:8]})") @@ -299,7 +312,7 @@ class CheckpointManager: ) if not ok: - return {"success": False, "error": f"Restore failed: {err}"} + return {"success": False, "error": "Restore failed", "debug": err or None} # Get info about what was restored ok2, reason_out, _ = _run_git( From 11825ccefabae376b89e8d0e1689f691d990d83a Mon Sep 17 00:00:00 2001 From: insecurejezza Date: Wed, 11 Mar 2026 09:15:31 -0700 Subject: [PATCH 10/12] feat(gateway): thread-aware free-response routing for Discord - Forum parent channel IDs now match free-response list (add a forum channel ID and all its threads respond without mention) - Better thread chat names: 'Guild / forum / thread' for forum threads - Add discord.require_mention and discord.free_response_channels to config.yaml (bridged to env vars, env vars still override) - Keep require_mention defaulting to true (safe for shared servers) Cherry-picked from PR #867 by insecurejezza with default fix and config.yaml integration. Co-authored-by: insecurejezza --- gateway/config.py | 12 + gateway/platforms/discord.py | 84 +++++-- hermes_cli/config.py | 6 + tests/gateway/test_discord_free_response.py | 249 ++++++++++++++++++++ 4 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 tests/gateway/test_discord_free_response.py diff --git a/gateway/config.py b/gateway/config.py index ba0840bfc..5d3dfa9f5 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -292,6 +292,18 @@ def load_gateway_config() -> GatewayConfig: sr = yaml_cfg.get("session_reset") if sr and isinstance(sr, dict): config.default_reset_policy = SessionResetPolicy.from_dict(sr) + + # Bridge discord settings from config.yaml to env vars + # (env vars take precedence — only set if not already defined) + discord_cfg = yaml_cfg.get("discord", {}) + if isinstance(discord_cfg, dict): + if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): + os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() + frc = discord_cfg.get("free_response_channels") + if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) except Exception: pass diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 04607ab07..c7ae2ada5 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -775,6 +775,46 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: return SendResult(success=False, error=str(e)) + def _get_parent_channel_id(self, channel: Any) -> Optional[str]: + """Return the parent channel ID for a Discord thread-like channel, if present.""" + parent = getattr(channel, "parent", None) + if parent is not None and getattr(parent, "id", None) is not None: + return str(parent.id) + parent_id = getattr(channel, "parent_id", None) + if parent_id is not None: + return str(parent_id) + return None + + def _is_forum_parent(self, channel: Any) -> bool: + """Best-effort check for whether a Discord channel is a forum channel.""" + if channel is None: + return False + forum_cls = getattr(discord, "ForumChannel", None) + if forum_cls and isinstance(channel, forum_cls): + return True + channel_type = getattr(channel, "type", None) + if channel_type is not None: + type_value = getattr(channel_type, "value", channel_type) + if type_value == 15: + return True + return False + + def _format_thread_chat_name(self, thread: Any) -> str: + """Build a readable chat name for thread-like Discord channels, including forum context when available.""" + thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread")) + parent = getattr(thread, "parent", None) + guild = getattr(thread, "guild", None) or getattr(parent, "guild", None) + guild_name = getattr(guild, "name", None) + parent_name = getattr(parent, "name", None) + + if self._is_forum_parent(parent) and guild_name and parent_name: + return f"{guild_name} / {parent_name} / {thread_name}" + if parent_name and guild_name: + return f"{guild_name} / #{parent_name} / {thread_name}" + if parent_name: + return f"{parent_name} / {thread_name}" + return thread_name + async def _handle_message(self, message: DiscordMessage) -> None: """Handle incoming Discord messages.""" # In server channels (not DMs), require the bot to be @mentioned @@ -785,28 +825,33 @@ class DiscordAdapter(BasePlatformAdapter): # bot responds to every message without needing a mention. # DISCORD_REQUIRE_MENTION: Set to "false" to disable mention requirement # globally (all channels become free-response). Default: "true". - + # Can also be set via discord.require_mention in config.yaml. + + thread_id = None + parent_channel_id = None + is_thread = isinstance(message.channel, discord.Thread) + if is_thread: + thread_id = str(message.channel.id) + parent_channel_id = self._get_parent_channel_id(message.channel) + if not isinstance(message.channel, discord.DMChannel): - # Check if this channel is in the free-response list free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} - channel_id = str(message.channel.id) - - # Global override: if DISCORD_REQUIRE_MENTION=false, all channels are free + channel_ids = {str(message.channel.id)} + if parent_channel_id: + channel_ids.add(parent_channel_id) + require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") - - is_free_channel = channel_id in free_channels - + is_free_channel = bool(channel_ids & free_channels) + if require_mention and not is_free_channel: - # Must be @mentioned to respond if self._client.user not in message.mentions: - return # Silently ignore messages that don't mention the bot - - # Strip the bot mention from the message text so the agent sees clean input + return + if self._client.user and self._client.user in message.mentions: message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip() message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip() - + # Determine message type msg_type = MessageType.TEXT if message.content.startswith("/"): @@ -829,20 +874,15 @@ class DiscordAdapter(BasePlatformAdapter): if isinstance(message.channel, discord.DMChannel): chat_type = "dm" chat_name = message.author.name - elif isinstance(message.channel, discord.Thread): + elif is_thread: chat_type = "thread" - chat_name = message.channel.name + chat_name = self._format_thread_chat_name(message.channel) else: - chat_type = "group" # Treat server channels as groups + chat_type = "group" chat_name = getattr(message.channel, "name", str(message.channel.id)) if hasattr(message.channel, "guild") and message.channel.guild: chat_name = f"{message.channel.guild.name} / #{chat_name}" - - # Get thread ID if in a thread - thread_id = None - if isinstance(message.channel, discord.Thread): - thread_id = str(message.channel.id) - + # Get channel topic (if available - TextChannels have topics, DMs/threads don't) chat_topic = getattr(message.channel, "topic", None) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f2b5d42c1..0094b94b5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -208,6 +208,12 @@ DEFAULT_CONFIG = { # Empty string means use server-local time. "timezone": "", + # Discord platform settings (gateway mode) + "discord": { + "require_mention": True, # Require @mention to respond in server channels + "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention + }, + # Permanently allowed dangerous command patterns (added via "always" approval) "command_allowlist": [], # User-defined quick commands that bypass the agent loop (type: exec only) diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py new file mode 100644 index 000000000..fd9eacab2 --- /dev/null +++ b/tests/gateway/test_discord_free_response.py @@ -0,0 +1,249 @@ +"""Tests for Discord free-response defaults and mention gating.""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import sys + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_discord_mock(): + """Install a mock discord module when discord.py isn't available.""" + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) + discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3) + discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +import gateway.platforms.discord as discord_platform # noqa: E402 +from gateway.platforms.discord import DiscordAdapter # noqa: E402 + + +class FakeDMChannel: + def __init__(self, channel_id: int = 1, name: str = "dm"): + self.id = channel_id + self.name = name + + +class FakeTextChannel: + def __init__(self, channel_id: int = 1, name: str = "general", guild_name: str = "Hermes Server"): + self.id = channel_id + self.name = name + self.guild = SimpleNamespace(name=guild_name) + self.topic = None + + +class FakeForumChannel: + def __init__(self, channel_id: int = 1, name: str = "support-forum", guild_name: str = "Hermes Server"): + self.id = channel_id + self.name = name + self.guild = SimpleNamespace(name=guild_name) + self.type = 15 + self.topic = None + + +class FakeThread: + def __init__(self, channel_id: int = 1, name: str = "thread", parent=None, guild_name: str = "Hermes Server"): + self.id = channel_id + self.name = name + self.parent = parent + self.parent_id = getattr(parent, "id", None) + self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name) + self.topic = None + + +@pytest.fixture +def adapter(monkeypatch): + monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False) + monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False) + monkeypatch.setattr(discord_platform.discord, "ForumChannel", FakeForumChannel, raising=False) + + config = PlatformConfig(enabled=True, token="fake-token") + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) + adapter.handle_message = AsyncMock() + return adapter + + +def make_message(*, channel, content: str, mentions=None): + author = SimpleNamespace(id=42, display_name="Jezza", name="Jezza") + return SimpleNamespace( + id=123, + content=content, + mentions=list(mentions or []), + attachments=[], + reference=None, + created_at=datetime.now(timezone.utc), + channel=channel, + author=author, + ) + + +@pytest.mark.asyncio +async def test_discord_defaults_to_require_mention(adapter, monkeypatch): + """Default behavior: require @mention in server channels.""" + monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel") + + await adapter._handle_message(message) + + # Should be ignored — no mention, require_mention defaults to true + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_free_response_in_server_channels(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello from channel" + assert event.source.chat_id == "123" + assert event.source.chat_type == "group" + + +@pytest.mark.asyncio +async def test_discord_free_response_in_threads(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + thread = FakeThread(channel_id=456, name="Ghost reader skill") + message = make_message(channel=thread, content="hello from thread") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello from thread" + assert event.source.chat_id == "456" + assert event.source.thread_id == "456" + assert event.source.chat_type == "thread" + + +@pytest.mark.asyncio +async def test_discord_forum_threads_are_handled_as_threads(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + forum = FakeForumChannel(channel_id=222, name="support-forum") + thread = FakeThread(channel_id=456, name="Can Hermes reply here?", parent=forum) + message = make_message(channel=thread, content="hello from forum post") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello from forum post" + assert event.source.chat_id == "456" + assert event.source.thread_id == "456" + assert event.source.chat_type == "thread" + assert event.source.chat_name == "Hermes Server / support-forum / Can Hermes reply here?" + + +@pytest.mark.asyncio +async def test_discord_can_still_require_mentions_when_enabled(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + message = make_message(channel=FakeTextChannel(channel_id=789), content="ignored without mention") + + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_free_response_channel_overrides_mention_requirement(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789,999") + + message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed without mention") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "allowed without mention" + + +@pytest.mark.asyncio +async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "222") + + forum = FakeForumChannel(channel_id=222, name="support-forum") + thread = FakeThread(channel_id=333, name="Forum topic", parent=forum) + message = make_message(channel=thread, content="allowed from forum thread") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "allowed from forum thread" + assert event.source.chat_id == "333" + + +@pytest.mark.asyncio +async def test_discord_accepts_and_strips_bot_mentions_when_required(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=321), + content=f"<@{bot_user.id}> hello with mention", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + + +@pytest.mark.asyncio +async def test_discord_dms_ignore_mention_requirement(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + message = make_message(channel=FakeDMChannel(channel_id=654), content="dm without mention") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "dm without mention" + assert event.source.chat_type == "dm" From 41fa4fbaa5dcc15ca996528af7ff7c7dd01d44ea Mon Sep 17 00:00:00 2001 From: aydnOktay Date: Wed, 11 Mar 2026 09:15:45 -0700 Subject: [PATCH 11/12] fix: add exc_info=True to image generation error logging Adds full stack traces to error logs in _upscale_image() and image_generate_tool() for better debugging. Matches the pattern used across the rest of the codebase. Cherry-picked from PR #868 by aydnOktay. Co-authored-by: aydnOktay --- tools/image_generation_tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 3789f38e7..00cc59128 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -209,7 +209,7 @@ def _upscale_image(image_url: str, original_prompt: str) -> Dict[str, Any]: return None except Exception as e: - logger.error("Error upscaling image: %s", e) + logger.error("Error upscaling image: %s", e, exc_info=True) return None @@ -377,7 +377,7 @@ def image_generate_tool( except Exception as e: generation_time = (datetime.datetime.now() - start_time).total_seconds() error_msg = f"Error generating image: {str(e)}" - logger.error("%s", error_msg) + logger.error("%s", error_msg, exc_info=True) # Prepare error response - minimal format response_data = { From 452593319b399be0c91b3dba6be05455df260500 Mon Sep 17 00:00:00 2001 From: kshitij-eliza <256820943+kshitij-eliza@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:33:29 +0530 Subject: [PATCH 12/12] fix(setup): preserve provider metadata during model selection --- hermes_cli/setup.py | 938 +++++++++++++++++++++++---------- tests/hermes_cli/test_setup.py | 130 +++++ 2 files changed, 781 insertions(+), 287 deletions(-) create mode 100644 tests/hermes_cli/test_setup.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index c471b1b9d..f533a9384 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -21,78 +21,133 @@ logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +def _model_config_dict(config: Dict[str, Any]) -> Dict[str, Any]: + current_model = config.get("model") + if isinstance(current_model, dict): + return dict(current_model) + if isinstance(current_model, str) and current_model.strip(): + return {"default": current_model.strip()} + return {} + + +def _set_model_provider( + config: Dict[str, Any], provider_id: str, base_url: str = "" +) -> None: + model_cfg = _model_config_dict(config) + model_cfg["provider"] = provider_id + if base_url: + model_cfg["base_url"] = base_url.rstrip("/") + else: + model_cfg.pop("base_url", None) + config["model"] = model_cfg + + +def _set_default_model(config: Dict[str, Any], model_name: str) -> None: + if not model_name: + return + model_cfg = _model_config_dict(config) + model_cfg["default"] = model_name + config["model"] = model_cfg + + +def _sync_model_from_disk(config: Dict[str, Any]) -> None: + disk_model = load_config().get("model") + if isinstance(disk_model, dict): + model_cfg = _model_config_dict(config) + model_cfg.update(disk_model) + config["model"] = model_cfg + elif isinstance(disk_model, str) and disk_model.strip(): + _set_default_model(config, disk_model.strip()) + + # Import config helpers from hermes_cli.config import ( - get_hermes_home, get_config_path, get_env_path, - load_config, save_config, save_env_value, get_env_value, - ensure_hermes_home, DEFAULT_CONFIG + get_hermes_home, + get_config_path, + get_env_path, + load_config, + save_config, + save_env_value, + get_env_value, + ensure_hermes_home, + DEFAULT_CONFIG, ) from hermes_cli.colors import Colors, color + def print_header(title: str): """Print a section header.""" print() print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + def print_info(text: str): """Print info text.""" print(color(f" {text}", Colors.DIM)) + def print_success(text: str): """Print success message.""" print(color(f"✓ {text}", Colors.GREEN)) + def print_warning(text: str): """Print warning message.""" print(color(f"⚠ {text}", Colors.YELLOW)) + def print_error(text: str): """Print error message.""" print(color(f"✗ {text}", Colors.RED)) + def prompt(question: str, default: str = None, password: bool = False) -> str: """Prompt for input with optional default.""" if default: display = f"{question} [{default}]: " else: display = f"{question}: " - + try: if password: import getpass + value = getpass.getpass(color(display, Colors.YELLOW)) else: value = input(color(display, Colors.YELLOW)) - + return value.strip() or default or "" except (KeyboardInterrupt, EOFError): print() sys.exit(1) + def prompt_choice(question: str, choices: list, default: int = 0) -> int: """Prompt for a choice from a list with arrow key navigation. - + Escape keeps the current default (skips the question). Ctrl+C exits the wizard. """ print(color(question, Colors.YELLOW)) - + # Try to use interactive menu if available try: from simple_term_menu import TerminalMenu import re - + # Strip emoji characters — simple_term_menu miscalculates visual # width of emojis, causing duplicated/garbled lines on redraw. _emoji_re = re.compile( "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f" - "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE + "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", + flags=re.UNICODE, ) menu_choices = [f" {_emoji_re.sub('', choice).strip()}" for choice in choices] - + print_info(" ↑/↓ Navigate Enter Select Esc Skip Ctrl+C Exit") - + terminal_menu = TerminalMenu( menu_choices, cursor_index=default, @@ -102,7 +157,7 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: cycle_cursor=True, clear_screen=False, ) - + idx = terminal_menu.show() if idx is None: # User pressed Escape — keep current value print_info(f" Skipped (keeping current)") @@ -110,7 +165,7 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: return default print() # Add newline after selection return idx - + except (ImportError, NotImplementedError): pass except Exception as e: @@ -128,7 +183,9 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: while True: try: - value = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM)) + value = input( + color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM) + ) if not value: return default idx = int(value) - 1 @@ -141,22 +198,27 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: print() sys.exit(1) + def prompt_yes_no(question: str, default: bool = True) -> bool: """Prompt for yes/no. Ctrl+C exits, empty input returns default.""" default_str = "Y/n" if default else "y/N" - + while True: try: - value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower() + value = ( + input(color(f"{question} [{default_str}]: ", Colors.YELLOW)) + .strip() + .lower() + ) except (KeyboardInterrupt, EOFError): print() sys.exit(1) - + if not value: return default - if value in ('y', 'yes'): + if value in ("y", "yes"): return True - if value in ('n', 'no'): + if value in ("n", "no"): return False print_error("Please enter 'y' or 'n'") @@ -164,40 +226,41 @@ def prompt_yes_no(question: str, default: bool = True) -> bool: def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list: """ Display a multi-select checklist and return the indices of selected items. - + Each item in `items` is a display string. `pre_selected` is a list of indices that should be checked by default. A "Continue →" option is appended at the end — the user toggles items with Space and confirms with Enter on "Continue →". - + Falls back to a numbered toggle interface when simple_term_menu is unavailable. - + Returns: List of selected indices (not including the Continue option). """ if pre_selected is None: pre_selected = [] - + print(color(title, Colors.YELLOW)) print_info(" SPACE Toggle ENTER Confirm ESC Skip Ctrl+C Exit") print() - + try: from simple_term_menu import TerminalMenu import re - + # Strip emoji characters from menu labels — simple_term_menu miscalculates # visual width of emojis on macOS, causing duplicated/garbled lines. _emoji_re = re.compile( "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f" - "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE + "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", + flags=re.UNICODE, ) menu_items = [f" {_emoji_re.sub('', item).strip()}" for item in items] - + # Map pre-selected indices to the actual menu entry strings preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)] - + terminal_menu = TerminalMenu( menu_items, multi_select=True, @@ -212,28 +275,30 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list cycle_cursor=True, clear_screen=False, ) - + terminal_menu.show() - + if terminal_menu.chosen_menu_entries is None: print_info(" Skipped (keeping current)") return list(pre_selected) - + selected = list(terminal_menu.chosen_menu_indices or []) return selected - + except (ImportError, NotImplementedError): # Fallback: numbered toggle interface (simple_term_menu doesn't support Windows) selected = set(pre_selected) - + while True: for i, item in enumerate(items): marker = color("[✓]", Colors.GREEN) if i in selected else "[ ]" print(f" {marker} {i + 1}. {item}") print() - + try: - value = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip() + value = input( + color(" Toggle # (or Enter to confirm): ", Colors.DIM) + ).strip() if not value: break idx = int(value) - 1 @@ -249,10 +314,10 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list except (KeyboardInterrupt, EOFError): print() return [] - + # Clear and redraw (simple approach) print() - + return sorted(selected) @@ -289,111 +354,137 @@ def _print_setup_summary(config: dict, hermes_home): # Tool availability summary print() print_header("Tool Availability Summary") - + tool_status = [] - + # OpenRouter (required for vision, moa) - if get_env_value('OPENROUTER_API_KEY'): + if get_env_value("OPENROUTER_API_KEY"): tool_status.append(("Vision (image analysis)", True, None)) tool_status.append(("Mixture of Agents", True, None)) else: tool_status.append(("Vision (image analysis)", False, "OPENROUTER_API_KEY")) tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY")) - + # Firecrawl (web tools) - if get_env_value('FIRECRAWL_API_KEY') or get_env_value('FIRECRAWL_API_URL'): + if get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"): tool_status.append(("Web Search & Extract", True, None)) else: tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY")) - + # Browser tools (local Chromium or Browserbase cloud) import shutil - _ab_found = shutil.which("agent-browser") or (Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser").exists() - if get_env_value('BROWSERBASE_API_KEY'): + + _ab_found = ( + shutil.which("agent-browser") + or ( + Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" + ).exists() + ) + if get_env_value("BROWSERBASE_API_KEY"): tool_status.append(("Browser Automation (Browserbase)", True, None)) elif _ab_found: tool_status.append(("Browser Automation (local)", True, None)) else: - tool_status.append(("Browser Automation", False, "npm install -g agent-browser")) - + tool_status.append( + ("Browser Automation", False, "npm install -g agent-browser") + ) + # FAL (image generation) - if get_env_value('FAL_KEY'): + if get_env_value("FAL_KEY"): tool_status.append(("Image Generation", True, None)) else: tool_status.append(("Image Generation", False, "FAL_KEY")) - + # TTS — show configured provider - tts_provider = config.get('tts', {}).get('provider', 'edge') - if tts_provider == 'elevenlabs' and get_env_value('ELEVENLABS_API_KEY'): + tts_provider = config.get("tts", {}).get("provider", "edge") + if tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"): tool_status.append(("Text-to-Speech (ElevenLabs)", True, None)) - elif tts_provider == 'openai' and get_env_value('VOICE_TOOLS_OPENAI_KEY'): + elif tts_provider == "openai" and get_env_value("VOICE_TOOLS_OPENAI_KEY"): tool_status.append(("Text-to-Speech (OpenAI)", True, None)) else: tool_status.append(("Text-to-Speech (Edge TTS)", True, None)) - + # Tinker + WandB (RL training) - if get_env_value('TINKER_API_KEY') and get_env_value('WANDB_API_KEY'): + if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"): tool_status.append(("RL Training (Tinker)", True, None)) - elif get_env_value('TINKER_API_KEY'): + elif get_env_value("TINKER_API_KEY"): tool_status.append(("RL Training (Tinker)", False, "WANDB_API_KEY")) else: tool_status.append(("RL Training (Tinker)", False, "TINKER_API_KEY")) - + # Home Assistant - if get_env_value('HASS_TOKEN'): + if get_env_value("HASS_TOKEN"): tool_status.append(("Smart Home (Home Assistant)", True, None)) - + # Skills Hub - if get_env_value('GITHUB_TOKEN'): + if get_env_value("GITHUB_TOKEN"): tool_status.append(("Skills Hub (GitHub)", True, None)) else: tool_status.append(("Skills Hub (GitHub)", False, "GITHUB_TOKEN")) - + # Terminal (always available if system deps met) tool_status.append(("Terminal/Commands", True, None)) - + # Task planning (always available, in-memory) tool_status.append(("Task Planning (todo)", True, None)) - + # Skills (always available -- bundled skills + user-created skills) tool_status.append(("Skills (view, create, edit)", True, None)) - + # Print status available_count = sum(1 for _, avail, _ in tool_status if avail) total_count = len(tool_status) - + print_info(f"{available_count}/{total_count} tool categories available:") print() - + for name, available, missing_var in tool_status: if available: print(f" {color('✓', Colors.GREEN)} {name}") else: - print(f" {color('✗', Colors.RED)} {name} {color(f'(missing {missing_var})', Colors.DIM)}") - + print( + f" {color('✗', Colors.RED)} {name} {color(f'(missing {missing_var})', Colors.DIM)}" + ) + print() - + disabled_tools = [(name, var) for name, avail, var in tool_status if not avail] if disabled_tools: - print_warning("Some tools are disabled. Run 'hermes setup tools' to configure them,") + print_warning( + "Some tools are disabled. Run 'hermes setup tools' to configure them," + ) print_warning("or edit ~/.hermes/.env directly to add the missing API keys.") print() - + # Done banner print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN)) - print(color("│ ✓ Setup Complete! │", Colors.GREEN)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN)) + print( + color( + "┌─────────────────────────────────────────────────────────┐", Colors.GREEN + ) + ) + print( + color( + "│ ✓ Setup Complete! │", Colors.GREEN + ) + ) + print( + color( + "└─────────────────────────────────────────────────────────┘", Colors.GREEN + ) + ) print() - + # Show file locations prominently print(color("📁 All your files are in ~/.hermes/:", Colors.CYAN, Colors.BOLD)) print() print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}") print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}") - print(f" {color('Data:', Colors.YELLOW)} {hermes_home}/cron/, sessions/, logs/") + print( + f" {color('Data:', Colors.YELLOW)} {hermes_home}/cron/, sessions/, logs/" + ) print() - + print(color("─" * 60, Colors.DIM)) print() print(color("📝 To edit your configuration:", Colors.CYAN, Colors.BOLD)) @@ -405,7 +496,9 @@ def _print_setup_summary(config: dict, hermes_home): print(f" {color('hermes setup tools', Colors.GREEN)} Configure tool providers") print() print(f" {color('hermes config', Colors.GREEN)} View current settings") - print(f" {color('hermes config edit', Colors.GREEN)} Open config in your editor") + print( + f" {color('hermes config edit', Colors.GREEN)} Open config in your editor" + ) print(f" {color('hermes config set KEY VALUE', Colors.GREEN)}") print(f" Set a specific value") print() @@ -413,7 +506,7 @@ def _print_setup_summary(config: dict, hermes_home): print(f" {color(f'nano {get_config_path()}', Colors.DIM)}") print(f" {color(f'nano {get_env_path()}', Colors.DIM)}") print() - + print(color("─" * 60, Colors.DIM)) print() print(color("🚀 Ready to go!", Colors.CYAN, Colors.BOLD)) @@ -426,45 +519,46 @@ def _print_setup_summary(config: dict, hermes_home): def _prompt_container_resources(config: dict): """Prompt for container resource settings (Docker, Singularity, Modal, Daytona).""" - terminal = config.setdefault('terminal', {}) + terminal = config.setdefault("terminal", {}) print() print_info("Container Resource Settings:") # Persistence - current_persist = terminal.get('container_persistent', True) + current_persist = terminal.get("container_persistent", True) persist_label = "yes" if current_persist else "no" print_info(" Persistent filesystem keeps files between sessions.") print_info(" Set to 'no' for ephemeral sandboxes that reset each time.") - persist_str = prompt(f" Persist filesystem across sessions? (yes/no)", persist_label) - terminal['container_persistent'] = persist_str.lower() in ('yes', 'true', 'y', '1') + persist_str = prompt( + f" Persist filesystem across sessions? (yes/no)", persist_label + ) + terminal["container_persistent"] = persist_str.lower() in ("yes", "true", "y", "1") # CPU - current_cpu = terminal.get('container_cpu', 1) + current_cpu = terminal.get("container_cpu", 1) cpu_str = prompt(f" CPU cores", str(current_cpu)) try: - terminal['container_cpu'] = float(cpu_str) + terminal["container_cpu"] = float(cpu_str) except ValueError: pass # Memory - current_mem = terminal.get('container_memory', 5120) + current_mem = terminal.get("container_memory", 5120) mem_str = prompt(f" Memory in MB (5120 = 5GB)", str(current_mem)) try: - terminal['container_memory'] = int(mem_str) + terminal["container_memory"] = int(mem_str) except ValueError: pass # Disk - current_disk = terminal.get('container_disk', 51200) + current_disk = terminal.get("container_disk", 51200) disk_str = prompt(f" Disk in MB (51200 = 50GB)", str(current_disk)) try: - terminal['container_disk'] = int(disk_str) + terminal["container_disk"] = int(disk_str) except ValueError: pass - # Tool categories and provider config are now in tools_config.py (shared # between `hermes tools` and `hermes setup tools`). @@ -473,13 +567,21 @@ def _prompt_container_resources(config: dict): # Section 1: Model & Provider Configuration # ============================================================================= + def setup_model_provider(config: dict): """Configure the inference provider and default model.""" from hermes_cli.auth import ( - get_active_provider, get_provider_auth_state, PROVIDER_REGISTRY, - format_auth_error, AuthError, fetch_nous_models, - resolve_nous_runtime_credentials, _update_config_for_provider, - _login_openai_codex, get_codex_auth_status, DEFAULT_CODEX_BASE_URL, + get_active_provider, + get_provider_auth_state, + PROVIDER_REGISTRY, + format_auth_error, + AuthError, + fetch_nous_models, + resolve_nous_runtime_credentials, + _update_config_for_provider, + _login_openai_codex, + get_codex_auth_status, + DEFAULT_CODEX_BASE_URL, detect_external_credentials, ) @@ -497,14 +599,14 @@ def setup_model_provider(config: dict): print_info("Detected existing credentials:") for cred in detected_creds: if cred["provider"] == "openai-codex": - print_success(f" * {cred['label']} -- select \"OpenAI Codex\" to use it") + print_success(f' * {cred["label"]} -- select "OpenAI Codex" to use it') else: print_info(f" * {cred['label']}") print() # Detect if any provider is already configured has_any_provider = bool(active_oauth or existing_custom or existing_or) - + # Build "keep current" label if active_oauth and active_oauth in PROVIDER_REGISTRY: keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})" @@ -528,18 +630,22 @@ def setup_model_provider(config: dict): ] if keep_label: provider_choices.append(keep_label) - + # Default to "Keep current" if a provider exists, otherwise OpenRouter (most common) default_provider = len(provider_choices) - 1 if has_any_provider else 3 - + if not has_any_provider: print_warning("An inference provider is required for Hermes to work.") print() - - provider_idx = prompt_choice("Select your inference provider:", provider_choices, default_provider) + + provider_idx = prompt_choice( + "Select your inference provider:", provider_choices, default_provider + ) # Track which provider was selected for model step - selected_provider = None # "nous", "openai-codex", "openrouter", "custom", or None (keep) + selected_provider = ( + None # "nous", "openai-codex", "openrouter", "custom", or None (keep) + ) nous_models = [] # populated if Nous login succeeds if provider_idx == 0: # Nous Portal API Key (direct) @@ -570,7 +676,12 @@ def setup_model_provider(config: dict): if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") - _update_config_for_provider("nous-api", "https://inference-api.nousresearch.com/v1") + _update_config_for_provider( + "nous-api", "https://inference-api.nousresearch.com/v1" + ) + _set_model_provider( + config, "nous-api", "https://inference-api.nousresearch.com/v1" + ) elif provider_idx == 1: # Nous Portal selected_provider = "nous" @@ -583,18 +694,26 @@ def setup_model_provider(config: dict): try: from hermes_cli.auth import _login_nous, ProviderConfig import argparse + mock_args = argparse.Namespace( - portal_url=None, inference_url=None, client_id=None, - scope=None, no_browser=False, timeout=15.0, - ca_bundle=None, insecure=False, + portal_url=None, + inference_url=None, + client_id=None, + scope=None, + no_browser=False, + timeout=15.0, + ca_bundle=None, + insecure=False, ) pconfig = PROVIDER_REGISTRY["nous"] _login_nous(mock_args, pconfig) + _sync_model_from_disk(config) # Fetch models for the selection step try: creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=5 * 60, timeout_seconds=15.0, + min_key_ttl_seconds=5 * 60, + timeout_seconds=15.0, ) nous_models = fetch_nous_models( inference_base_url=creds.get("base_url", ""), @@ -620,6 +739,7 @@ def setup_model_provider(config: dict): try: import argparse + mock_args = argparse.Namespace() _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) # Clear custom endpoint vars that would override provider routing. @@ -627,6 +747,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) + _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) except SystemExit: print_warning("OpenAI Codex login was cancelled or failed.") print_info("You can try again later with: hermes model") @@ -667,11 +788,15 @@ def setup_model_provider(config: dict): # resolver doesn't keep returning the old provider (e.g. Codex). try: from hermes_cli.auth import deactivate_provider + deactivate_provider() except Exception: pass import yaml - config_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" + + config_path = ( + Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" + ) try: disk_cfg = {} if config_path.exists(): @@ -683,6 +808,7 @@ def setup_model_provider(config: dict): model_section.pop("base_url", None) # OpenRouter uses default URL disk_cfg["model"] = model_section config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False)) + _set_model_provider(config, "openrouter") except Exception as e: logger.debug("Could not save provider to config.yaml: %s", e) @@ -694,15 +820,21 @@ def setup_model_provider(config: dict): current_url = get_env_value("OPENAI_BASE_URL") or "" current_key = get_env_value("OPENAI_API_KEY") - _raw_model = config.get('model', '') - current_model = _raw_model.get('default', '') if isinstance(_raw_model, dict) else (_raw_model or '') + _raw_model = config.get("model", "") + current_model = ( + _raw_model.get("default", "") + if isinstance(_raw_model, dict) + else (_raw_model or "") + ) if current_url: print_info(f" Current URL: {current_url}") if current_key: print_info(f" Current key: {current_key[:8]}... (configured)") - base_url = prompt(" API base URL (e.g., https://api.example.com/v1)", current_url) + base_url = prompt( + " API base URL (e.g., https://api.example.com/v1)", current_url + ) api_key = prompt(" API key", password=True) model_name = prompt(" Model name (e.g., gpt-4, claude-3-opus)", current_model) @@ -711,14 +843,25 @@ def setup_model_provider(config: dict): if api_key: save_env_value("OPENAI_API_KEY", api_key) if model_name: - config['model'] = model_name + _set_default_model(config, model_name) save_env_value("LLM_MODEL", model_name) + try: + from hermes_cli.auth import deactivate_provider + + deactivate_provider() + except Exception: + pass + # Save provider and base_url to config.yaml so the gateway and CLI # both resolve the correct provider without relying on env-var heuristics. if base_url: import yaml - config_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" + + config_path = ( + Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) + / "config.yaml" + ) try: disk_cfg = {} if config_path.exists(): @@ -735,6 +878,8 @@ def setup_model_provider(config: dict): except Exception as e: logger.debug("Could not save provider to config.yaml: %s", e) + _set_model_provider(config, "custom", base_url) + print_success("Custom endpoint configured") elif provider_idx == 5: # Z.AI / GLM @@ -772,24 +917,30 @@ def setup_model_provider(config: dict): print() print_info("Detecting your z.ai endpoint...") from hermes_cli.auth import detect_zai_endpoint + detected = detect_zai_endpoint(api_key) if detected: zai_base_url = detected["base_url"] print_success(f"Detected: {detected['label']} endpoint") print_info(f" URL: {detected['base_url']}") if detected["id"].startswith("coding"): - print_info(f" Note: Coding Plan detected — GLM-5 is not available, using {detected['model']}") + print_info( + f" Note: Coding Plan detected — GLM-5 is not available, using {detected['model']}" + ) save_env_value("GLM_BASE_URL", zai_base_url) else: print_warning("Could not verify any z.ai endpoint with this key.") print_info(f" Using default: {zai_base_url}") - print_info(" If you get billing errors, check your plan at https://open.bigmodel.cn/") + print_info( + " If you get billing errors, check your plan at https://open.bigmodel.cn/" + ) # Clear custom endpoint vars if switching if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("zai", zai_base_url) + _set_model_provider(config, "zai", zai_base_url) elif provider_idx == 6: # Kimi / Moonshot selected_provider = "kimi-coding" @@ -822,6 +973,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("kimi-coding", pconfig.inference_base_url) + _set_model_provider(config, "kimi-coding", pconfig.inference_base_url) elif provider_idx == 7: # MiniMax selected_provider = "minimax" @@ -854,6 +1006,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax", pconfig.inference_base_url) + _set_model_provider(config, "minimax", pconfig.inference_base_url) elif provider_idx == 8: # MiniMax China selected_provider = "minimax-cn" @@ -886,32 +1039,50 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax-cn", pconfig.inference_base_url) + _set_model_provider(config, "minimax-cn", pconfig.inference_base_url) # else: provider_idx == 9 (Keep current) — only shown when a provider already exists # ── OpenRouter API Key for tools (if not already set) ── # Tools (vision, web, MoA) use OpenRouter independently of the main provider. # Prompt for OpenRouter key if not set and a non-OpenRouter provider was chosen. - if selected_provider in ("nous", "nous-api", "openai-codex", "custom", "zai", "kimi-coding", "minimax", "minimax-cn") and not get_env_value("OPENROUTER_API_KEY"): + if selected_provider in ( + "nous", + "nous-api", + "openai-codex", + "custom", + "zai", + "kimi-coding", + "minimax", + "minimax-cn", + ) and not get_env_value("OPENROUTER_API_KEY"): print() print_header("OpenRouter API Key (for tools)") print_info("Tools like vision analysis, web search, and MoA use OpenRouter") print_info("independently of your main inference provider.") print_info("Get your API key at: https://openrouter.ai/keys") - api_key = prompt(" OpenRouter API key (optional, press Enter to skip)", password=True) + api_key = prompt( + " OpenRouter API key (optional, press Enter to skip)", password=True + ) if api_key: save_env_value("OPENROUTER_API_KEY", api_key) print_success("OpenRouter API key saved (for tools)") else: - print_info("Skipped - some tools (vision, web scraping) won't work without this") + print_info( + "Skipped - some tools (vision, web scraping) won't work without this" + ) # ── Model Selection (adapts based on provider) ── if selected_provider != "custom": # Custom already prompted for model name print_header("Default Model") - _raw_model = config.get('model', 'anthropic/claude-opus-4.6') - current_model = _raw_model.get('default', 'anthropic/claude-opus-4.6') if isinstance(_raw_model, dict) else (_raw_model or 'anthropic/claude-opus-4.6') + _raw_model = config.get("model", "anthropic/claude-opus-4.6") + current_model = ( + _raw_model.get("default", "anthropic/claude-opus-4.6") + if isinstance(_raw_model, dict) + else (_raw_model or "anthropic/claude-opus-4.6") + ) print_info(f"Current: {current_model}") if selected_provider == "nous" and nous_models: @@ -922,18 +1093,24 @@ def setup_model_provider(config: dict): # Post-login validation: warn if current model might not be available if current_model and current_model not in nous_models: - print_warning(f"Your current model ({current_model}) may not be available via Nous Portal.") - print_info("Select a model from the list, or keep current to use it anyway.") + print_warning( + f"Your current model ({current_model}) may not be available via Nous Portal." + ) + print_info( + "Select a model from the list, or keep current to use it anyway." + ) print() - model_idx = prompt_choice("Select default model:", model_choices, len(model_choices) - 1) + model_idx = prompt_choice( + "Select default model:", model_choices, len(model_choices) - 1 + ) if model_idx < len(nous_models): - config['model'] = nous_models[model_idx] + _set_default_model(config, nous_models[model_idx]) elif model_idx == len(model_choices) - 2: # Custom model_name = prompt(" Model name") if model_name: - config['model'] = model_name + _set_default_model(config, model_name) # else: keep current elif selected_provider == "nous": @@ -943,7 +1120,7 @@ def setup_model_provider(config: dict): print_info("Enter a Nous model name manually (e.g., claude-opus-4-6).") custom = prompt(f" Model name (Enter to keep '{current_model}')") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) elif selected_provider == "nous-api": # Nous API key provider — prompt for model manually @@ -951,10 +1128,11 @@ def setup_model_provider(config: dict): print_info("Examples: anthropic/claude-opus-4.6, deepseek/deepseek-r1") custom = prompt(f" Model name (Enter to keep '{current_model}')") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) elif selected_provider == "openai-codex": from hermes_cli.codex_models import get_codex_model_ids + codex_models = get_codex_model_ids() model_choices = codex_models + [f"Keep current ({current_model})"] default_codex = 0 @@ -963,19 +1141,24 @@ def setup_model_provider(config: dict): elif current_model: default_codex = len(model_choices) - 1 - model_idx = prompt_choice("Select default model:", model_choices, default_codex) + model_idx = prompt_choice( + "Select default model:", model_choices, default_codex + ) if model_idx < len(codex_models): - config['model'] = codex_models[model_idx] + _set_default_model(config, codex_models[model_idx]) save_env_value("LLM_MODEL", codex_models[model_idx]) elif model_idx == len(codex_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) + _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) elif selected_provider == "zai": # Coding Plan endpoints don't have GLM-5 - is_coding_plan = get_env_value("GLM_BASE_URL") and "coding" in (get_env_value("GLM_BASE_URL") or "") + is_coding_plan = get_env_value("GLM_BASE_URL") and "coding" in ( + get_env_value("GLM_BASE_URL") or "" + ) if is_coding_plan: zai_models = ["glm-4.7", "glm-4.5", "glm-4.5-flash"] else: @@ -988,12 +1171,12 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(zai_models): - config['model'] = zai_models[model_idx] + _set_default_model(config, zai_models[model_idx]) save_env_value("LLM_MODEL", zai_models[model_idx]) elif model_idx == len(zai_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: keep current elif selected_provider == "kimi-coding": @@ -1006,12 +1189,12 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(kimi_models): - config['model'] = kimi_models[model_idx] + _set_default_model(config, kimi_models[model_idx]) save_env_value("LLM_MODEL", kimi_models[model_idx]) elif model_idx == len(kimi_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: keep current elif selected_provider in ("minimax", "minimax-cn"): @@ -1024,12 +1207,12 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(minimax_models): - config['model'] = minimax_models[model_idx] + _set_default_model(config, minimax_models[model_idx]) save_env_value("LLM_MODEL", minimax_models[model_idx]) elif model_idx == len(minimax_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: keep current else: @@ -1046,18 +1229,22 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(ids): - config['model'] = ids[model_idx] + _set_default_model(config, ids[model_idx]) save_env_value("LLM_MODEL", ids[model_idx]) elif model_idx == len(ids): # Custom custom = prompt("Enter model name (e.g., anthropic/claude-opus-4.6)") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: Keep current - _final_model = config.get('model', '') + _final_model = config.get("model", "") if _final_model: - _display = _final_model.get('default', _final_model) if isinstance(_final_model, dict) else _final_model + _display = ( + _final_model.get("default", _final_model) + if isinstance(_final_model, dict) + else _final_model + ) print_success(f"Model set to: {_display}") save_config(config) @@ -1067,6 +1254,7 @@ def setup_model_provider(config: dict): # Section 2: Terminal Backend Configuration # ============================================================================= + def setup_terminal_backend(config: dict): """Configure the terminal execution backend.""" import platform as _platform @@ -1077,7 +1265,7 @@ def setup_terminal_backend(config: dict): print_info("This affects tool execution, file access, and isolation.") print() - current_backend = config.get('terminal', {}).get('backend', 'local') + current_backend = config.get("terminal", {}).get("backend", "local") is_linux = _platform.system() == "Linux" # Build backend choices with descriptions @@ -1105,7 +1293,9 @@ def setup_terminal_backend(config: dict): default_terminal = backend_to_idx.get(current_backend, 0) - terminal_idx = prompt_choice("Select terminal backend:", terminal_choices, keep_current_idx) + terminal_idx = prompt_choice( + "Select terminal backend:", terminal_choices, keep_current_idx + ) selected_backend = idx_to_backend.get(terminal_idx) @@ -1113,21 +1303,23 @@ def setup_terminal_backend(config: dict): print_info(f"Keeping current backend: {current_backend}") return - config.setdefault('terminal', {})['backend'] = selected_backend + config.setdefault("terminal", {})["backend"] = selected_backend if selected_backend == "local": print_success("Terminal backend: Local") print_info("Commands run directly on this machine.") - + # CWD for messaging print() print_info("Working directory for messaging sessions:") print_info(" When using Hermes via Telegram/Discord, this is where") - print_info(" the agent starts. CLI mode always starts in the current directory.") - current_cwd = config.get('terminal', {}).get('cwd', '') + print_info( + " the agent starts. CLI mode always starts in the current directory." + ) + current_cwd = config.get("terminal", {}).get("cwd", "") cwd = prompt(" Messaging working directory", current_cwd or str(Path.home())) if cwd: - config['terminal']['cwd'] = cwd + config["terminal"]["cwd"] = cwd # Sudo support print() @@ -1135,7 +1327,9 @@ def setup_terminal_backend(config: dict): if existing_sudo: print_info("Sudo password: configured") else: - if prompt_yes_no("Enable sudo support? (stores password for apt install, etc.)", False): + if prompt_yes_no( + "Enable sudo support? (stores password for apt install, etc.)", False + ): sudo_pass = prompt(" Sudo password", password=True) if sudo_pass: save_env_value("SUDO_PASSWORD", sudo_pass) @@ -1153,9 +1347,11 @@ def setup_terminal_backend(config: dict): print_info(f"Docker found: {docker_bin}") # Docker image - current_image = config.get('terminal', {}).get('docker_image', 'python:3.11-slim') + current_image = config.get("terminal", {}).get( + "docker_image", "python:3.11-slim" + ) image = prompt(" Docker image", current_image) - config['terminal']['docker_image'] = image + config["terminal"]["docker_image"] = image save_env_value("TERMINAL_DOCKER_IMAGE", image) _prompt_container_resources(config) @@ -1167,13 +1363,17 @@ def setup_terminal_backend(config: dict): sing_bin = shutil.which("apptainer") or shutil.which("singularity") if not sing_bin: print_warning("Singularity/Apptainer not found in PATH!") - print_info("Install: https://apptainer.org/docs/admin/main/installation.html") + print_info( + "Install: https://apptainer.org/docs/admin/main/installation.html" + ) else: print_info(f"Found: {sing_bin}") - current_image = config.get('terminal', {}).get('singularity_image', 'docker://python:3.11-slim') + current_image = config.get("terminal", {}).get( + "singularity_image", "docker://python:3.11-slim" + ) image = prompt(" Container image", current_image) - config['terminal']['singularity_image'] = image + config["terminal"]["singularity_image"] = image save_env_value("TERMINAL_SINGULARITY_IMAGE", image) _prompt_container_resources(config) @@ -1189,21 +1389,33 @@ def setup_terminal_backend(config: dict): except ImportError: print_info("Installing swe-rex[modal]...") import subprocess + uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, "swe-rex[modal]"], - capture_output=True, text=True + [ + uv_bin, + "pip", + "install", + "--python", + sys.executable, + "swe-rex[modal]", + ], + capture_output=True, + text=True, ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "swe-rex[modal]"], - capture_output=True, text=True + capture_output=True, + text=True, ) if result.returncode == 0: print_success("swe-rex[modal] installed") else: - print_warning("Install failed — run manually: pip install 'swe-rex[modal]'") + print_warning( + "Install failed — run manually: pip install 'swe-rex[modal]'" + ) # Modal token print() @@ -1241,16 +1453,19 @@ def setup_terminal_backend(config: dict): except ImportError: print_info("Installing daytona SDK...") import subprocess + uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "--python", sys.executable, "daytona"], - capture_output=True, text=True + capture_output=True, + text=True, ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "daytona"], - capture_output=True, text=True + capture_output=True, + text=True, ) if result.returncode == 0: print_success("daytona SDK installed") @@ -1276,9 +1491,11 @@ def setup_terminal_backend(config: dict): print_success(" Configured") # Daytona image - current_image = config.get('terminal', {}).get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20') + current_image = config.get("terminal", {}).get( + "daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20" + ) image = prompt(" Sandbox image", current_image) - config['terminal']['daytona_image'] = image + config["terminal"]["daytona_image"] = image save_env_value("TERMINAL_DAYTONA_IMAGE", image) _prompt_container_resources(config) @@ -1316,6 +1533,7 @@ def setup_terminal_backend(config: dict): if host and prompt_yes_no(" Test SSH connection?", True): print_info(" Testing connection...") import subprocess + ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"] if ssh_key: ssh_cmd.extend(["-i", ssh_key]) @@ -1342,28 +1560,31 @@ def setup_terminal_backend(config: dict): # Section 3: Agent Settings # ============================================================================= + def setup_agent_settings(config: dict): """Configure agent behavior: iterations, progress display, compression, session reset.""" # ── Max Iterations ── print_header("Agent Settings") - current_max = get_env_value('HERMES_MAX_ITERATIONS') or str(config.get('agent', {}).get('max_turns', 90)) + current_max = get_env_value("HERMES_MAX_ITERATIONS") or str( + config.get("agent", {}).get("max_turns", 90) + ) print_info("Maximum tool-calling iterations per conversation.") print_info("Higher = more complex tasks, but costs more tokens.") print_info("Recommended: 30-60 for most tasks, 100+ for open exploration.") - + max_iter_str = prompt("Max iterations", current_max) try: max_iter = int(max_iter_str) if max_iter > 0: save_env_value("HERMES_MAX_ITERATIONS", str(max_iter)) - config.setdefault('agent', {})['max_turns'] = max_iter - config.pop('max_turns', None) + config.setdefault("agent", {})["max_turns"] = max_iter + config.pop("max_turns", None) print_success(f"Max iterations set to {max_iter}") except ValueError: print_warning("Invalid number, keeping current value") - + # ── Tool Progress Display ── print_info("") print_info("Tool Progress Display") @@ -1372,7 +1593,7 @@ def setup_agent_settings(config: dict): print_info(" new — Show tool name only when it changes (less noise)") print_info(" all — Show every tool call with a short preview") print_info(" verbose — Full args, results, and debug logs") - + current_mode = config.get("display", {}).get("tool_progress", "all") mode = prompt("Tool progress mode", current_mode) if mode.lower() in ("off", "new", "all", "verbose"): @@ -1387,33 +1608,47 @@ def setup_agent_settings(config: dict): # ── Context Compression ── print_header("Context Compression") print_info("Automatically summarizes old messages when context gets too long.") - print_info("Higher threshold = compress later (use more context). Lower = compress sooner.") - - config.setdefault('compression', {})['enabled'] = True - - current_threshold = config.get('compression', {}).get('threshold', 0.85) + print_info( + "Higher threshold = compress later (use more context). Lower = compress sooner." + ) + + config.setdefault("compression", {})["enabled"] = True + + current_threshold = config.get("compression", {}).get("threshold", 0.85) threshold_str = prompt("Compression threshold (0.5-0.95)", str(current_threshold)) try: threshold = float(threshold_str) if 0.5 <= threshold <= 0.95: - config['compression']['threshold'] = threshold + config["compression"]["threshold"] = threshold except ValueError: pass - - print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}") + + print_success( + f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}" + ) # ── Session Reset Policy ── print_header("Session Reset Policy") - print_info("Messaging sessions (Telegram, Discord, etc.) accumulate context over time.") - print_info("Each message adds to the conversation history, which means growing API costs.") + print_info( + "Messaging sessions (Telegram, Discord, etc.) accumulate context over time." + ) + print_info( + "Each message adds to the conversation history, which means growing API costs." + ) print_info("") - print_info("To manage this, sessions can automatically reset after a period of inactivity") - print_info("or at a fixed time each day. When a reset happens, the agent saves important") - print_info("things to its persistent memory first — but the conversation context is cleared.") + print_info( + "To manage this, sessions can automatically reset after a period of inactivity" + ) + print_info( + "or at a fixed time each day. When a reset happens, the agent saves important" + ) + print_info( + "things to its persistent memory first — but the conversation context is cleared." + ) print_info("") print_info("You can also manually reset anytime by typing /reset in chat.") print_info("") - + reset_choices = [ "Inactivity + daily reset (recommended - reset whichever comes first)", "Inactivity only (reset after N minutes of no messages)", @@ -1421,61 +1656,71 @@ def setup_agent_settings(config: dict): "Never auto-reset (context lives until /reset or context compression)", "Keep current settings", ] - - current_policy = config.get('session_reset', {}) - current_mode = current_policy.get('mode', 'both') - current_idle = current_policy.get('idle_minutes', 1440) - current_hour = current_policy.get('at_hour', 4) - + + current_policy = config.get("session_reset", {}) + current_mode = current_policy.get("mode", "both") + current_idle = current_policy.get("idle_minutes", 1440) + current_hour = current_policy.get("at_hour", 4) + default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 0) - + reset_idx = prompt_choice("Session reset mode:", reset_choices, default_reset) - - config.setdefault('session_reset', {}) - + + config.setdefault("session_reset", {}) + if reset_idx == 0: # Both - config['session_reset']['mode'] = 'both' + config["session_reset"]["mode"] = "both" idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) try: idle_val = int(idle_str) if idle_val > 0: - config['session_reset']['idle_minutes'] = idle_val + config["session_reset"]["idle_minutes"] = idle_val except ValueError: pass hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) try: hour_val = int(hour_str) if 0 <= hour_val <= 23: - config['session_reset']['at_hour'] = hour_val + config["session_reset"]["at_hour"] = hour_val except ValueError: pass - print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00") + print_success( + f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00" + ) elif reset_idx == 1: # Idle only - config['session_reset']['mode'] = 'idle' + config["session_reset"]["mode"] = "idle" idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) try: idle_val = int(idle_str) if idle_val > 0: - config['session_reset']['idle_minutes'] = idle_val + config["session_reset"]["idle_minutes"] = idle_val except ValueError: pass - print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity") + print_success( + f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity" + ) elif reset_idx == 2: # Daily only - config['session_reset']['mode'] = 'daily' + config["session_reset"]["mode"] = "daily" hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) try: hour_val = int(hour_str) if 0 <= hour_val <= 23: - config['session_reset']['at_hour'] = hour_val + config["session_reset"]["at_hour"] = hour_val except ValueError: pass - print_success(f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00") + print_success( + f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00" + ) elif reset_idx == 3: # None - config['session_reset']['mode'] = 'none' - print_info("Sessions will never auto-reset. Context is managed only by compression.") - print_warning("Long conversations will grow in cost. Use /reset manually when needed.") + config["session_reset"]["mode"] = "none" + print_info( + "Sessions will never auto-reset. Context is managed only by compression." + ) + print_warning( + "Long conversations will grow in cost. Use /reset manually when needed." + ) # else: keep current (idx == 4) - + save_config(config) @@ -1483,6 +1728,7 @@ def setup_agent_settings(config: dict): # Section 4: Messaging Platforms (Gateway) # ============================================================================= + def setup_gateway(config: dict): """Configure messaging platform integrations.""" print_header("Messaging Platforms") @@ -1490,19 +1736,19 @@ def setup_gateway(config: dict): print() # ── Telegram ── - existing_telegram = get_env_value('TELEGRAM_BOT_TOKEN') + existing_telegram = get_env_value("TELEGRAM_BOT_TOKEN") if existing_telegram: print_info("Telegram: already configured") if prompt_yes_no("Reconfigure Telegram?", False): existing_telegram = None - + if not existing_telegram and prompt_yes_no("Set up Telegram bot?", False): print_info("Create a bot via @BotFather on Telegram") token = prompt("Telegram bot token", password=True) if token: save_env_value("TELEGRAM_BOT_TOKEN", token) print_success("Telegram token saved") - + # Allowed users (security) print() print_info("🔒 Security: Restrict who can use your bot") @@ -1510,60 +1756,74 @@ def setup_gateway(config: dict): print_info(" 1. Message @userinfobot on Telegram") print_info(" 2. It will reply with your numeric ID (e.g., 123456789)") print() - allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + allowed_users = prompt( + "Allowed user IDs (comma-separated, leave empty for open access)" + ) if allowed_users: save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) - print_success("Telegram allowlist configured - only listed users can use the bot") + print_success( + "Telegram allowlist configured - only listed users can use the bot" + ) else: - print_info("⚠️ No allowlist set - anyone who finds your bot can use it!") - + print_info( + "⚠️ No allowlist set - anyone who finds your bot can use it!" + ) + # Home channel setup with better guidance print() print_info("📬 Home Channel: where Hermes delivers cron job results,") print_info(" cross-platform messages, and notifications.") print_info(" For Telegram DMs, this is your user ID (same as above).") - + first_user_id = allowed_users.split(",")[0].strip() if allowed_users else "" if first_user_id: - if prompt_yes_no(f"Use your user ID ({first_user_id}) as the home channel?", True): + if prompt_yes_no( + f"Use your user ID ({first_user_id}) as the home channel?", True + ): save_env_value("TELEGRAM_HOME_CHANNEL", first_user_id) print_success(f"Telegram home channel set to {first_user_id}") else: - home_channel = prompt("Home channel ID (or leave empty to set later with /set-home in Telegram)") + home_channel = prompt( + "Home channel ID (or leave empty to set later with /set-home in Telegram)" + ) if home_channel: save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) else: - print_info(" You can also set this later by typing /set-home in your Telegram chat.") + print_info( + " You can also set this later by typing /set-home in your Telegram chat." + ) home_channel = prompt("Home channel ID (leave empty to set later)") if home_channel: save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) - + # Check/update existing Telegram allowlist elif existing_telegram: - existing_allowlist = get_env_value('TELEGRAM_ALLOWED_USERS') + existing_allowlist = get_env_value("TELEGRAM_ALLOWED_USERS") if not existing_allowlist: print_info("⚠️ Telegram has no user allowlist - anyone can use your bot!") if prompt_yes_no("Add allowed users now?", True): print_info(" To find your Telegram user ID: message @userinfobot") allowed_users = prompt("Allowed user IDs (comma-separated)") if allowed_users: - save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) + save_env_value( + "TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "") + ) print_success("Telegram allowlist configured") - + # ── Discord ── - existing_discord = get_env_value('DISCORD_BOT_TOKEN') + existing_discord = get_env_value("DISCORD_BOT_TOKEN") if existing_discord: print_info("Discord: already configured") if prompt_yes_no("Reconfigure Discord?", False): existing_discord = None - + if not existing_discord and prompt_yes_no("Set up Discord bot?", False): print_info("Create a bot at https://discord.com/developers/applications") token = prompt("Discord bot token", password=True) if token: save_env_value("DISCORD_BOT_TOKEN", token) print_success("Discord token saved") - + # Allowed users (security) print() print_info("🔒 Security: Restrict who can use your bot") @@ -1571,48 +1831,66 @@ def setup_gateway(config: dict): print_info(" 1. Enable Developer Mode in Discord settings") print_info(" 2. Right-click your name → Copy ID") print() - print_info(" You can also use Discord usernames (resolved on gateway start).") + print_info( + " You can also use Discord usernames (resolved on gateway start)." + ) print() - allowed_users = prompt("Allowed user IDs or usernames (comma-separated, leave empty for open access)") + allowed_users = prompt( + "Allowed user IDs or usernames (comma-separated, leave empty for open access)" + ) if allowed_users: save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Discord allowlist configured") else: - print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!") - + print_info( + "⚠️ No allowlist set - anyone in servers with your bot can use it!" + ) + # Home channel setup with better guidance print() print_info("📬 Home Channel: where Hermes delivers cron job results,") print_info(" cross-platform messages, and notifications.") - print_info(" To get a channel ID: right-click a channel → Copy Channel ID") + print_info( + " To get a channel ID: right-click a channel → Copy Channel ID" + ) print_info(" (requires Developer Mode in Discord settings)") - print_info(" You can also set this later by typing /set-home in a Discord channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + print_info( + " You can also set this later by typing /set-home in a Discord channel." + ) + home_channel = prompt( + "Home channel ID (leave empty to set later with /set-home)" + ) if home_channel: save_env_value("DISCORD_HOME_CHANNEL", home_channel) - + # Check/update existing Discord allowlist elif existing_discord: - existing_allowlist = get_env_value('DISCORD_ALLOWED_USERS') + existing_allowlist = get_env_value("DISCORD_ALLOWED_USERS") if not existing_allowlist: print_info("⚠️ Discord has no user allowlist - anyone can use your bot!") if prompt_yes_no("Add allowed users now?", True): - print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") + print_info( + " To find Discord ID: Enable Developer Mode, right-click name → Copy ID" + ) allowed_users = prompt("Allowed user IDs (comma-separated)") if allowed_users: - save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) + save_env_value( + "DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "") + ) print_success("Discord allowlist configured") - + # ── Slack ── - existing_slack = get_env_value('SLACK_BOT_TOKEN') + existing_slack = get_env_value("SLACK_BOT_TOKEN") if existing_slack: print_info("Slack: already configured") if prompt_yes_no("Reconfigure Slack?", False): existing_slack = None - + if not existing_slack and prompt_yes_no("Set up Slack bot?", False): print_info("Steps to create a Slack app:") - print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)") + print_info( + " 1. Go to https://api.slack.com/apps → Create New App (from scratch)" + ) print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable") print_info(" • Create an App-Level Token with 'connections:write' scope") print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions") @@ -1625,9 +1903,13 @@ def setup_gateway(config: dict): print_warning(" ⚠ Without message.channels/message.groups events,") print_warning(" the bot will ONLY work in DMs, not channels!") print_info(" 5. Install to Workspace: Settings → Install App") - print_info(" 6. After installing, invite the bot to channels: /invite @YourBot") + print_info( + " 6. After installing, invite the bot to channels: /invite @YourBot" + ) print() - print_info(" Full guide: https://hermes-agent.ai/docs/user-guide/messaging/slack") + print_info( + " Full guide: https://hermes-agent.ai/docs/user-guide/messaging/slack" + ) print() bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) if bot_token: @@ -1636,20 +1918,26 @@ def setup_gateway(config: dict): if app_token: save_env_value("SLACK_APP_TOKEN", app_token) print_success("Slack tokens saved") - + print() print_info("🔒 Security: Restrict who can use your bot") - print_info(" To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID") + print_info( + " To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID" + ) print() - allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + allowed_users = prompt( + "Allowed user IDs (comma-separated, leave empty for open access)" + ) if allowed_users: save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Slack allowlist configured") else: - print_info("⚠️ No allowlist set - anyone in your workspace can use the bot!") - + print_info( + "⚠️ No allowlist set - anyone in your workspace can use the bot!" + ) + # ── WhatsApp ── - existing_whatsapp = get_env_value('WHATSAPP_ENABLED') + existing_whatsapp = get_env_value("WHATSAPP_ENABLED") if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False): print_info("WhatsApp connects via a built-in bridge (Baileys).") print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.") @@ -1659,13 +1947,13 @@ def setup_gateway(config: dict): print_success("WhatsApp enabled") print_info("Run 'hermes whatsapp' to choose your mode (separate bot number") print_info("or personal self-chat) and pair via QR code.") - + # ── Gateway Service Setup ── any_messaging = ( - get_env_value('TELEGRAM_BOT_TOKEN') - or get_env_value('DISCORD_BOT_TOKEN') - or get_env_value('SLACK_BOT_TOKEN') - or get_env_value('WHATSAPP_ENABLED') + get_env_value("TELEGRAM_BOT_TOKEN") + or get_env_value("DISCORD_BOT_TOKEN") + or get_env_value("SLACK_BOT_TOKEN") + or get_env_value("WHATSAPP_ENABLED") ) if any_messaging: print() @@ -1674,11 +1962,15 @@ def setup_gateway(config: dict): # Check if any home channels are missing missing_home = [] - if get_env_value('TELEGRAM_BOT_TOKEN') and not get_env_value('TELEGRAM_HOME_CHANNEL'): + if get_env_value("TELEGRAM_BOT_TOKEN") and not get_env_value( + "TELEGRAM_HOME_CHANNEL" + ): missing_home.append("Telegram") - if get_env_value('DISCORD_BOT_TOKEN') and not get_env_value('DISCORD_HOME_CHANNEL'): + if get_env_value("DISCORD_BOT_TOKEN") and not get_env_value( + "DISCORD_HOME_CHANNEL" + ): missing_home.append("Discord") - if get_env_value('SLACK_BOT_TOKEN') and not get_env_value('SLACK_HOME_CHANNEL'): + if get_env_value("SLACK_BOT_TOKEN") and not get_env_value("SLACK_HOME_CHANNEL"): missing_home.append("Slack") if missing_home: @@ -1688,17 +1980,25 @@ def setup_gateway(config: dict): print_info(" messages can't be delivered to those platforms.") print_info(" Set one later with /set-home in your chat, or:") for plat in missing_home: - print_info(f" hermes config set {plat.upper()}_HOME_CHANNEL ") + print_info( + f" hermes config set {plat.upper()}_HOME_CHANNEL " + ) # Offer to install the gateway as a system service import platform as _platform + _is_linux = _platform.system() == "Linux" _is_macos = _platform.system() == "Darwin" from hermes_cli.gateway import ( - _is_service_installed, _is_service_running, - systemd_install, systemd_start, systemd_restart, - launchd_install, launchd_start, launchd_restart, + _is_service_installed, + _is_service_running, + systemd_install, + systemd_start, + systemd_restart, + launchd_install, + launchd_start, + launchd_restart, ) service_installed = _is_service_installed() @@ -1725,7 +2025,10 @@ def setup_gateway(config: dict): print_error(f" Start failed: {e}") elif _is_linux or _is_macos: svc_name = "systemd" if _is_linux else "launchd" - if prompt_yes_no(f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", True): + if prompt_yes_no( + f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", + True, + ): try: if _is_linux: systemd_install(force=False) @@ -1757,17 +2060,19 @@ def setup_gateway(config: dict): # Section 5: Tool Configuration (delegates to unified tools_config.py) # ============================================================================= + def setup_tools(config: dict, first_install: bool = False): """Configure tools — delegates to the unified tools_command() in tools_config.py. - + Both `hermes setup tools` and `hermes tools` use the same flow: platform selection → toolset toggles → provider/API key configuration. - + Args: first_install: When True, uses the simplified first-install flow (no platform menu, prompts for all unconfigured API keys). """ from hermes_cli.tools_config import tools_command + tools_command(first_install=first_install, config=config) @@ -1786,7 +2091,7 @@ SETUP_SECTIONS = [ def run_setup_wizard(args): """Run the interactive setup wizard. - + Supports full, quick, and section-specific setup: hermes setup — full or quick (auto-detected) hermes setup model — just model/provider @@ -1796,46 +2101,84 @@ def run_setup_wizard(args): hermes setup agent — just agent settings """ ensure_hermes_home() - + config = load_config() hermes_home = get_hermes_home() - + # Check if a specific section was requested - section = getattr(args, 'section', None) + section = getattr(args, "section", None) if section: for key, label, func in SETUP_SECTIONS: if key == section: print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) print(color(f"│ ⚕ Hermes Setup — {label:<34s} │", Colors.MAGENTA)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) func(config) save_config(config) print() print_success(f"{label} configuration complete!") return - + print_error(f"Unknown setup section: {section}") print_info(f"Available sections: {', '.join(k for k, _, _ in SETUP_SECTIONS)}") return - + # Check if this is an existing installation with a provider configured from hermes_cli.auth import get_active_provider + active_provider = get_active_provider() is_existing = ( bool(get_env_value("OPENROUTER_API_KEY")) or bool(get_env_value("OPENAI_BASE_URL")) or active_provider is not None ) - + print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) - print(color("│ ⚕ Hermes Agent Setup Wizard │", Colors.MAGENTA)) - print(color("├─────────────────────────────────────────────────────────┤", Colors.MAGENTA)) - print(color("│ Let's configure your Hermes Agent installation. │", Colors.MAGENTA)) - print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) - + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) + print( + color( + "│ ⚕ Hermes Agent Setup Wizard │", Colors.MAGENTA + ) + ) + print( + color( + "├─────────────────────────────────────────────────────────┤", + Colors.MAGENTA, + ) + ) + print( + color( + "│ Let's configure your Hermes Agent installation. │", Colors.MAGENTA + ) + ) + print( + color( + "│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA + ) + ) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) + if is_existing: # ── Returning User Menu ── print() @@ -1931,20 +2274,31 @@ def run_setup_wizard(args): def _run_quick_setup(config: dict, hermes_home): """Quick setup — only configure items that are missing.""" from hermes_cli.config import ( - get_missing_env_vars, get_missing_config_fields, - check_config_version, migrate_config, + get_missing_env_vars, + get_missing_config_fields, + check_config_version, + migrate_config, ) print() print_header("Quick Setup — Missing Items Only") # Check what's missing - missing_required = [v for v in get_missing_env_vars(required_only=False) if v.get("is_required")] - missing_optional = [v for v in get_missing_env_vars(required_only=False) if not v.get("is_required")] + missing_required = [ + v for v in get_missing_env_vars(required_only=False) if v.get("is_required") + ] + missing_optional = [ + v for v in get_missing_env_vars(required_only=False) if not v.get("is_required") + ] missing_config = get_missing_config_fields() current_ver, latest_ver = check_config_version() - has_anything_missing = missing_required or missing_optional or missing_config or current_ver < latest_ver + has_anything_missing = ( + missing_required + or missing_optional + or missing_config + or current_ver < latest_ver + ) if not has_anything_missing: print_success("Everything is configured! Nothing to do.") @@ -1967,12 +2321,12 @@ def _run_quick_setup(config: dict, hermes_home): print_info(f" {var.get('description', '')}") if var.get("url"): print_info(f" Get key at: {var['url']}") - + if var.get("password"): value = prompt(f" {var.get('prompt', var['name'])}", password=True) else: value = prompt(f" {var.get('prompt', var['name'])}") - + if value: save_env_value(var["name"], value) print_success(f" Saved {var['name']}") @@ -1981,7 +2335,11 @@ def _run_quick_setup(config: dict, hermes_home): # Split missing optional vars by category missing_tools = [v for v in missing_optional if v.get("category") == "tool"] - missing_messaging = [v for v in missing_optional if v.get("category") == "messaging" and not v.get("advanced")] + missing_messaging = [ + v + for v in missing_optional + if v.get("category") == "messaging" and not v.get("advanced") + ] # ── Tool API keys (checklist) ── if missing_tools: @@ -2028,7 +2386,11 @@ def _run_quick_setup(config: dict, hermes_home): platforms.setdefault(plat, []).append(var) platform_labels = [ - {"Telegram": "📱 Telegram", "Discord": "💬 Discord", "Slack": "💼 Slack"}.get(p, p) + { + "Telegram": "📱 Telegram", + "Discord": "💬 Discord", + "Slack": "💼 Slack", + }.get(p, p) for p in platform_order ] @@ -2062,10 +2424,12 @@ def _run_quick_setup(config: dict, hermes_home): # Handle missing config fields if missing_config: print() - print_info(f"Adding {len(missing_config)} new config option(s) with defaults...") + print_info( + f"Adding {len(missing_config)} new config option(s) with defaults..." + ) for field in missing_config: print_success(f" Added {field['key']} = {field['default']}") - + # Update config version config["_config_version"] = latest_ver save_config(config) diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py new file mode 100644 index 000000000..3c3de3208 --- /dev/null +++ b/tests/hermes_cli/test_setup.py @@ -0,0 +1,130 @@ +import json + +from hermes_cli.auth import _update_config_for_provider, get_active_provider +from hermes_cli.config import load_config, save_config +from hermes_cli.setup import setup_model_provider + + +def _clear_provider_env(monkeypatch): + for key in ( + "NOUS_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_API_KEY", + "LLM_MODEL", + ): + monkeypatch.delenv(key, raising=False) + + +def test_nous_api_setup_preserves_model_provider_metadata(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 0) + + prompt_values = iter( + [ + "nous-api-key", + "", + "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + ] + ) + monkeypatch.setattr( + "hermes_cli.setup.prompt", + lambda *args, **kwargs: next(prompt_values), + ) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "nous-api" + assert reloaded["model"]["base_url"] == "https://inference-api.nousresearch.com/v1" + assert ( + reloaded["model"]["default"] + == "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8" + ) + + +def test_nous_oauth_setup_keeps_current_model_when_syncing_disk_provider( + tmp_path, monkeypatch +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + + prompt_choices = iter([1, 2]) + monkeypatch.setattr( + "hermes_cli.setup.prompt_choice", + lambda *args, **kwargs: next(prompt_choices), + ) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + + def _fake_login_nous(*args, **kwargs): + auth_path = tmp_path / "auth.json" + auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}})) + _update_config_for_provider("nous", "https://inference.example.com/v1") + + monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous) + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_runtime_credentials", + lambda *args, **kwargs: { + "base_url": "https://inference.example.com/v1", + "api_key": "nous-key", + }, + ) + monkeypatch.setattr( + "hermes_cli.auth.fetch_nous_models", + lambda *args, **kwargs: ["gemini-3-flash"], + ) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "nous" + assert reloaded["model"]["base_url"] == "https://inference.example.com/v1" + assert reloaded["model"]["default"] == "anthropic/claude-opus-4.6" + + +def test_custom_setup_clears_active_oauth_provider(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + auth_path = tmp_path / "auth.json" + auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}})) + + config = load_config() + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 4) + + prompt_values = iter( + [ + "https://custom.example/v1", + "custom-api-key", + "custom/model", + "", + ] + ) + monkeypatch.setattr( + "hermes_cli.setup.prompt", + lambda *args, **kwargs: next(prompt_values), + ) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + + assert get_active_provider() is None + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "custom" + assert reloaded["model"]["base_url"] == "https://custom.example/v1" + assert reloaded["model"]["default"] == "custom/model"