mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(gateway): bypass active-session guard for gateway-handled slash commands
This commit is contained in:
parent
d465fc5869
commit
511ed4dacc
5 changed files with 114 additions and 15 deletions
|
|
@ -1579,20 +1579,9 @@ class BasePlatformAdapter(ABC):
|
|||
# session lifecycle and its cleanup races with the running task
|
||||
# (see PR #4926).
|
||||
cmd = event.get_command()
|
||||
if cmd in (
|
||||
"approve",
|
||||
"deny",
|
||||
"status",
|
||||
"agents",
|
||||
"tasks",
|
||||
"stop",
|
||||
"new",
|
||||
"reset",
|
||||
"background",
|
||||
"restart",
|
||||
"queue",
|
||||
"q",
|
||||
):
|
||||
from hermes_cli.commands import should_bypass_active_session
|
||||
|
||||
if should_bypass_active_session(cmd):
|
||||
logger.debug(
|
||||
"[%s] Command '/%s' bypassing active-session guard for %s",
|
||||
self.name, cmd, session_key,
|
||||
|
|
|
|||
|
|
@ -2952,7 +2952,10 @@ class GatewayRunner:
|
|||
return await self._handle_status_command(event)
|
||||
|
||||
# Resolve the command once for all early-intercept checks below.
|
||||
from hermes_cli.commands import resolve_command as _resolve_cmd_inner
|
||||
from hermes_cli.commands import (
|
||||
resolve_command as _resolve_cmd_inner,
|
||||
should_bypass_active_session as _should_bypass_active_inner,
|
||||
)
|
||||
_evt_cmd = event.get_command()
|
||||
_cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
|
||||
|
||||
|
|
@ -3038,6 +3041,20 @@ class GatewayRunner:
|
|||
if _cmd_def_inner and _cmd_def_inner.name == "background":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
# Gateway-handled info/control commands must never fall through to
|
||||
# the interrupt path. If they are queued as pending text, the
|
||||
# slash-command safety net discards them before the user sees any
|
||||
# response.
|
||||
if _cmd_def_inner and _should_bypass_active_inner(_cmd_def_inner.name):
|
||||
if _cmd_def_inner.name == "help":
|
||||
return await self._handle_help_command(event)
|
||||
if _cmd_def_inner.name == "commands":
|
||||
return await self._handle_commands_command(event)
|
||||
if _cmd_def_inner.name == "profile":
|
||||
return await self._handle_profile_command(event)
|
||||
if _cmd_def_inner.name == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
if event.message_type == MessageType.PHOTO:
|
||||
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
|
||||
adapter = self.adapters.get(source.platform)
|
||||
|
|
|
|||
|
|
@ -258,6 +258,35 @@ GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
|||
)
|
||||
|
||||
|
||||
# Commands that must never be queued behind an active gateway session.
|
||||
# These are explicit control/info commands handled by the gateway itself;
|
||||
# if they get queued as pending text, the safety net in gateway.run will
|
||||
# discard them before they ever reach the user.
|
||||
ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"agents",
|
||||
"approve",
|
||||
"background",
|
||||
"commands",
|
||||
"deny",
|
||||
"help",
|
||||
"new",
|
||||
"profile",
|
||||
"queue",
|
||||
"restart",
|
||||
"status",
|
||||
"stop",
|
||||
"update",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def should_bypass_active_session(command_name: str | None) -> bool:
|
||||
"""Return True when a slash command must bypass active-session queuing."""
|
||||
cmd = resolve_command(command_name) if command_name else None
|
||||
return bool(cmd and cmd.name in ACTIVE_SESSION_BYPASS_COMMANDS)
|
||||
|
||||
|
||||
def _resolve_config_gates() -> set[str]:
|
||||
"""Return canonical names of commands whose ``gateway_config_gate`` is truthy.
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,38 @@ class TestCommandBypassActiveSession:
|
|||
"/background response was not sent back to the user"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_bypasses_guard(self):
|
||||
"""/help must bypass so it is not silently dropped as pending slash text."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/help"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/help was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:help" in r for r in adapter.sent_responses), (
|
||||
"/help response was not sent back to the user"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_bypasses_guard(self):
|
||||
"""/update must bypass so it is not discarded by the pending-command safety net."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/update"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/update was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:update" in r for r in adapter.sent_responses), (
|
||||
"/update response was not sent back to the user"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_bypasses_guard(self):
|
||||
"""/queue must bypass so it can queue without interrupting."""
|
||||
|
|
|
|||
|
|
@ -288,6 +288,38 @@ async def test_command_messages_do_not_leave_sentinel():
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("command_text", "handler_attr", "handler_result"),
|
||||
[
|
||||
("/help", "_handle_help_command", "Help text"),
|
||||
("/commands", "_handle_commands_command", "Commands text"),
|
||||
("/update", "_handle_update_command", "Update text"),
|
||||
("/profile", "_handle_profile_command", "Profile text"),
|
||||
],
|
||||
)
|
||||
async def test_active_session_bypass_commands_dispatch_without_interrupt(
|
||||
command_text,
|
||||
handler_attr,
|
||||
handler_result,
|
||||
):
|
||||
"""Gateway-handled bypass commands must return directly while an agent runs."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(text=command_text)
|
||||
session_key = build_session_key(event.source)
|
||||
|
||||
fake_agent = MagicMock()
|
||||
fake_agent.get_activity_summary.return_value = {"seconds_since_activity": 0}
|
||||
runner._running_agents[session_key] = fake_agent
|
||||
setattr(runner, handler_attr, AsyncMock(return_value=handler_result))
|
||||
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert result == handler_result
|
||||
fake_agent.interrupt.assert_not_called()
|
||||
assert session_key not in runner.adapters[Platform.TELEGRAM]._pending_messages
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Test 6: /stop during sentinel force-cleans and unlocks session
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue