Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson
2026-04-14 19:33:03 -05:00
28 changed files with 2378 additions and 541 deletions

View File

@@ -0,0 +1,95 @@
"""Tests for the auto-continue feature (#4493).
When the gateway restarts mid-agent-work, the session transcript ends on a
tool result that the agent never processed. The auto-continue logic detects
this and prepends a system note to the next user message so the model
finishes the interrupted work before addressing the new input.
"""
import pytest
def _simulate_auto_continue(agent_history: list, user_message: str) -> str:
"""Reproduce the auto-continue injection logic from _run_agent().
This mirrors the exact code in gateway/run.py so we can test the
detection and message transformation without spinning up a full
gateway runner.
"""
message = user_message
if agent_history and agent_history[-1].get("role") == "tool":
message = (
"[System note: Your previous turn was interrupted before you could "
"process the last tool result(s). The conversation history contains "
"tool outputs you haven't responded to yet. Please finish processing "
"those results and summarize what was accomplished, then address the "
"user's new message below.]\n\n"
+ message
)
return message
class TestAutoDetection:
"""Test that trailing tool results are correctly detected."""
def test_trailing_tool_result_triggers_note(self):
history = [
{"role": "user", "content": "deploy the app"},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_1", "function": {"name": "terminal", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "call_1", "content": "deployed successfully"},
]
result = _simulate_auto_continue(history, "what happened?")
assert "[System note:" in result
assert "interrupted" in result
assert "what happened?" in result
def test_trailing_assistant_message_no_note(self):
history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
]
result = _simulate_auto_continue(history, "how are you?")
assert "[System note:" not in result
assert result == "how are you?"
def test_empty_history_no_note(self):
result = _simulate_auto_continue([], "hello")
assert result == "hello"
def test_trailing_user_message_no_note(self):
"""Shouldn't happen in practice, but ensure no false positive."""
history = [
{"role": "user", "content": "hello"},
]
result = _simulate_auto_continue(history, "hello again")
assert result == "hello again"
def test_multiple_tool_results_still_triggers(self):
"""Multiple tool calls in a row — last one is still role=tool."""
history = [
{"role": "user", "content": "search and read"},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_1", "function": {"name": "search", "arguments": "{}"}},
{"id": "call_2", "function": {"name": "read", "arguments": "{}"}},
]},
{"role": "tool", "tool_call_id": "call_1", "content": "found it"},
{"role": "tool", "tool_call_id": "call_2", "content": "file content here"},
]
result = _simulate_auto_continue(history, "continue")
assert "[System note:" in result
def test_original_message_preserved_after_note(self):
"""The user's actual message must appear after the system note."""
history = [
{"role": "assistant", "content": None, "tool_calls": [
{"id": "c1", "function": {"name": "t", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "c1", "content": "done"},
]
result = _simulate_auto_continue(history, "now do X")
# System note comes first, then user's message
note_end = result.index("]\n\n")
user_msg_start = result.index("now do X")
assert user_msg_start > note_end

View File

@@ -19,10 +19,34 @@ def _ensure_discord_mock():
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.Interaction = object
# Lightweight mock for app_commands.Group and Command used by
# _register_skill_group.
class _FakeGroup:
def __init__(self, *, name, description, parent=None):
self.name = name
self.description = description
self.parent = parent
self._children: dict[str, object] = {}
if parent is not None:
parent.add_command(self)
def add_command(self, cmd):
self._children[cmd.name] = cmd
class _FakeCommand:
def __init__(self, *, name, description, callback, parent=None):
self.name = name
self.description = description
self.callback = callback
self.parent = parent
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
Group=_FakeGroup,
Command=_FakeCommand,
)
ext_mod = MagicMock()
@@ -51,6 +75,12 @@ class FakeTree:
return decorator
def add_command(self, cmd):
self.commands[cmd.name] = cmd
def get_commands(self):
return [SimpleNamespace(name=n) for n in self.commands]
@pytest.fixture
def adapter():
@@ -87,6 +117,23 @@ async def test_registers_native_thread_slash_command(adapter):
adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440)
@pytest.mark.asyncio
async def test_registers_native_restart_slash_command(adapter):
adapter._run_simple_slash = AsyncMock()
adapter._register_slash_commands()
assert "restart" in adapter._client.tree.commands
interaction = SimpleNamespace()
await adapter._client.tree.commands["restart"](interaction)
adapter._run_simple_slash.assert_awaited_once_with(
interaction,
"/restart",
"Restart requested~",
)
# ------------------------------------------------------------------
# _handle_thread_create_slash — success, session dispatch, failure
# ------------------------------------------------------------------
@@ -498,3 +545,79 @@ def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
import os
assert os.getenv("DISCORD_AUTO_THREAD") == "true"
# ------------------------------------------------------------------
# /skill group registration
# ------------------------------------------------------------------
def test_register_skill_group_creates_group(adapter):
"""_register_skill_group should register a '/skill' Group on the tree."""
mock_categories = {
"creative": [
("ascii-art", "Generate ASCII art", "/ascii-art"),
("excalidraw", "Hand-drawn diagrams", "/excalidraw"),
],
"media": [
("gif-search", "Search for GIFs", "/gif-search"),
],
}
mock_uncategorized = [
("dogfood", "Exploratory QA testing", "/dogfood"),
]
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=(mock_categories, mock_uncategorized, 0),
):
adapter._register_slash_commands()
tree = adapter._client.tree
assert "skill" in tree.commands, "Expected /skill group to be registered"
skill_group = tree.commands["skill"]
assert skill_group.name == "skill"
# Should have 2 category subgroups + 1 uncategorized subcommand
children = skill_group._children
assert "creative" in children
assert "media" in children
assert "dogfood" in children
# Category groups should have their skills
assert "ascii-art" in children["creative"]._children
assert "excalidraw" in children["creative"]._children
assert "gif-search" in children["media"]._children
def test_register_skill_group_empty_skills_no_group(adapter):
"""No /skill group should be added when there are zero skills."""
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=({}, [], 0),
):
adapter._register_slash_commands()
tree = adapter._client.tree
assert "skill" not in tree.commands
def test_register_skill_group_handler_dispatches_command(adapter):
"""Skill subcommand handlers should dispatch the correct /cmd-key text."""
mock_categories = {
"media": [
("gif-search", "Search for GIFs", "/gif-search"),
],
}
with patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=(mock_categories, [], 0),
):
adapter._register_slash_commands()
skill_group = adapter._client.tree.commands["skill"]
media_group = skill_group._children["media"]
gif_cmd = media_group._children["gif-search"]
assert gif_cmd.callback is not None
# The callback name should reflect the skill
assert "gif_search" in gif_cmd.callback.__name__

View File

@@ -193,7 +193,7 @@ async def test_shutdown_notification_says_restarting_when_restart_requested():
assert len(adapter.sent) == 1
assert "restarting" in adapter.sent[0]
assert "/retry" in adapter.sent[0]
assert "resume" in adapter.sent[0]
@pytest.mark.asyncio

View File

@@ -0,0 +1,116 @@
"""Tests for stuck-session loop detection (#7536).
When a session is active across 3+ consecutive gateway restarts (the agent
gets stuck, gateway restarts, same session gets stuck again), the session
is auto-suspended on startup so the user gets a clean slate.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from tests.gateway.restart_test_helpers import make_restart_runner
@pytest.fixture
def runner_with_home(tmp_path, monkeypatch):
"""Create a runner with a writable HERMES_HOME."""
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
runner, adapter = make_restart_runner()
return runner, tmp_path
class TestStuckLoopDetection:
def test_increment_creates_file(self, runner_with_home):
runner, home = runner_with_home
runner._increment_restart_failure_counts({"session:a", "session:b"})
path = home / runner._STUCK_LOOP_FILE
assert path.exists()
counts = json.loads(path.read_text())
assert counts["session:a"] == 1
assert counts["session:b"] == 1
def test_increment_accumulates(self, runner_with_home):
runner, home = runner_with_home
runner._increment_restart_failure_counts({"session:a"})
runner._increment_restart_failure_counts({"session:a"})
runner._increment_restart_failure_counts({"session:a"})
counts = json.loads((home / runner._STUCK_LOOP_FILE).read_text())
assert counts["session:a"] == 3
def test_increment_drops_inactive_sessions(self, runner_with_home):
runner, home = runner_with_home
runner._increment_restart_failure_counts({"session:a", "session:b"})
runner._increment_restart_failure_counts({"session:a"}) # b not active
counts = json.loads((home / runner._STUCK_LOOP_FILE).read_text())
assert "session:a" in counts
assert "session:b" not in counts
def test_suspend_at_threshold(self, runner_with_home):
runner, home = runner_with_home
# Simulate 3 restarts with session:a active each time
for _ in range(3):
runner._increment_restart_failure_counts({"session:a"})
# Create a mock session entry
mock_entry = MagicMock()
mock_entry.suspended = False
runner.session_store._entries = {"session:a": mock_entry}
runner.session_store._save = MagicMock()
suspended = runner._suspend_stuck_loop_sessions()
assert suspended == 1
assert mock_entry.suspended is True
def test_no_suspend_below_threshold(self, runner_with_home):
runner, home = runner_with_home
runner._increment_restart_failure_counts({"session:a"})
runner._increment_restart_failure_counts({"session:a"})
# Only 2 restarts — below threshold of 3
mock_entry = MagicMock()
mock_entry.suspended = False
runner.session_store._entries = {"session:a": mock_entry}
suspended = runner._suspend_stuck_loop_sessions()
assert suspended == 0
assert mock_entry.suspended is False
def test_clear_on_success(self, runner_with_home):
runner, home = runner_with_home
runner._increment_restart_failure_counts({"session:a", "session:b"})
runner._clear_restart_failure_count("session:a")
path = home / runner._STUCK_LOOP_FILE
counts = json.loads(path.read_text())
assert "session:a" not in counts
assert "session:b" in counts
def test_clear_removes_file_when_empty(self, runner_with_home):
runner, home = runner_with_home
runner._increment_restart_failure_counts({"session:a"})
runner._clear_restart_failure_count("session:a")
assert not (home / runner._STUCK_LOOP_FILE).exists()
def test_suspend_clears_file(self, runner_with_home):
runner, home = runner_with_home
for _ in range(3):
runner._increment_restart_failure_counts({"session:a"})
mock_entry = MagicMock()
mock_entry.suspended = False
runner.session_store._entries = {"session:a": mock_entry}
runner.session_store._save = MagicMock()
runner._suspend_stuck_loop_sessions()
assert not (home / runner._STUCK_LOOP_FILE).exists()
def test_no_file_no_crash(self, runner_with_home):
runner, home = runner_with_home
# No file exists — should return 0 and not crash
assert runner._suspend_stuck_loop_sessions() == 0
# Clear on nonexistent file — should not crash
runner._clear_restart_failure_count("nonexistent")