From 5692faace0d954222998d581156af22595919753 Mon Sep 17 00:00:00 2001 From: nftpoetrist Date: Sun, 3 May 2026 11:05:20 +0300 Subject: [PATCH 1/2] fix(setup): add missing SLACK_HOME_CHANNEL prompt to _setup_slack() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _setup_slack() was the only platform setup function that did not prompt for a home channel. All four sibling setups (_setup_telegram, _setup_discord, _setup_mattermost, _setup_bluebubbles) close with an identical home-channel block, and setup_gateway() already checks for SLACK_HOME_CHANNEL presence at the end of the wizard — but the value was never collected, leaving cron delivery and cross-platform notifications silently broken for Slack after a fresh hermes setup run. Add the standard home-channel prompt at the end of _setup_slack(), symmetric with the Discord implementation. Add two unit tests that verify the prompt is saved when provided and skipped when left blank. --- hermes_cli/setup.py | 10 ++++++++++ tests/hermes_cli/test_setup.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 31cb8460122b8..65f0d935ca64c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2049,6 +2049,16 @@ def _setup_slack(): print_warning("⚠️ No Slack allowlist set - unpaired users will be denied by default.") print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.") + 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: open the channel in Slack, then right-click") + print_info(" the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).") + print_info(" You can also set this later by typing /set-home in a Slack channel.") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + if home_channel: + save_env_value("SLACK_HOME_CHANNEL", home_channel.strip()) + def _write_slack_manifest_and_instruct(): """Generate the Slack manifest, write it under HERMES_HOME, and print diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 72adc27c0c2cc..f7b491ddf31ed 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -613,3 +613,35 @@ def fake_execvp(path, argv): setup_mod._offer_launch_chat() assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])] + + +def test_setup_slack_saves_home_channel(monkeypatch): + """_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one.""" + saved = {} + prompts = iter(["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"]) + + monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "") + monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v})) + monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts)) + monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False) + monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None) + + setup_mod._setup_slack() + + assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F" + + +def test_setup_slack_home_channel_empty_not_saved(monkeypatch): + """_setup_slack() does not save SLACK_HOME_CHANNEL when left blank.""" + saved = {} + prompts = iter(["xoxb-test-token", "xapp-test-token", "", ""]) + + monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "") + monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v})) + monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts)) + monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False) + monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None) + + setup_mod._setup_slack() + + assert "SLACK_HOME_CHANNEL" not in saved From ea98f200deb62fe83cbdacf5c25b7ee90cad5a77 Mon Sep 17 00:00:00 2001 From: nftpoetrist Date: Sun, 3 May 2026 11:35:12 +0300 Subject: [PATCH 2/2] fix(delegate): inherit parent fallback_chain in _build_child_agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_child_agent constructed child AIAgents without passing fallback_model, leaving _fallback_chain=[] for every subagent. When a subagent hit a rate-limit or credential exhaustion the runtime fallback check (run_agent.py:7486 / 12267) found an empty chain and failed immediately — even though the parent agent was configured with fallback_providers and would have recovered. The cron scheduler already propagates fallback_model correctly (scheduler.py:1038). Fix closes the parity gap by reading the parent's _fallback_chain (the normalised list form accepted by AIAgent's fallback_model parameter) and threading it through. Empty chains coerce to None so AIAgent initialises _fallback_chain=[] as usual rather than iterating an empty list. --- tests/tools/test_delegate.py | 47 ++++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 7 ++++++ 2 files changed, 54 insertions(+) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 1806a7e60fb76..089c46da09bb3 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -2403,5 +2403,52 @@ def worker(): self.assertIsNone(_get_approval_callback()) +class TestFallbackModelInheritance(unittest.TestCase): + """Subagents must inherit the parent's fallback provider chain.""" + + def test_child_inherits_fallback_chain(self): + """_build_child_agent passes parent._fallback_chain as fallback_model.""" + parent = _make_mock_parent(depth=0) + fallback_entry = {"provider": "openrouter", "model": "gpt-4o-mini", "api_key": "sk-or-x"} + parent._fallback_chain = [fallback_entry] + + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = MagicMock() + _build_child_agent( + task_index=0, + goal="test fallback inheritance", + context=None, + toolsets=None, + model=None, + max_iterations=10, + parent_agent=parent, + task_count=1, + ) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["fallback_model"], [fallback_entry]) + + def test_child_gets_no_fallback_when_parent_chain_empty(self): + """When parent._fallback_chain is empty, fallback_model is None.""" + parent = _make_mock_parent(depth=0) + parent._fallback_chain = [] + + with patch("run_agent.AIAgent") as MockAgent: + MockAgent.return_value = MagicMock() + _build_child_agent( + task_index=0, + goal="test no fallback", + context=None, + toolsets=None, + model=None, + max_iterations=10, + parent_agent=parent, + task_count=1, + ) + + _, kwargs = MockAgent.call_args + self.assertIsNone(kwargs["fallback_model"]) + + if __name__ == "__main__": unittest.main() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 844e7bdfb0e33..55c8ad31c4fa9 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1026,6 +1026,12 @@ def _child_thinking(text: str) -> None: except Exception as exc: logger.debug("Could not load delegation reasoning_effort: %s", exc) + # Inherit the parent's fallback provider chain so subagents can recover + # from rate-limits and credential exhaustion exactly like the top-level + # agent does. _fallback_chain is a list accepted by AIAgent's + # fallback_model parameter (which handles both list and dict forms). + parent_fallback = getattr(parent_agent, "_fallback_chain", None) or None + child = AIAgent( base_url=effective_base_url, api_key=effective_api_key, @@ -1038,6 +1044,7 @@ def _child_thinking(text: str) -> None: max_tokens=getattr(parent_agent, "max_tokens", None), reasoning_config=child_reasoning, prefill_messages=getattr(parent_agent, "prefill_messages", None), + fallback_model=parent_fallback, enabled_toolsets=child_toolsets, quiet_mode=True, ephemeral_system_prompt=child_prompt,