mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
Merge pull request #49583 from NousResearch/salvage/signal-mention-typing
fix(signal): salvage self-mention strip + explicit stop-typing RPC (batch of #31217, #40054)
This commit is contained in:
commit
b88d0007c9
3 changed files with 155 additions and 2 deletions
|
|
@ -621,6 +621,27 @@ class SignalAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return
|
||||
|
||||
# Strip the bot's own @mention from any group message so the agent
|
||||
# doesn't misinterpret "@+155****4567 say hello" as a directive to
|
||||
# contact that phone number. _render_mentions replaces the Signal
|
||||
#  placeholder with @<number-or-uuid>, which looks like an
|
||||
# addressee to the LLM rather than a self-reference. Applies to every
|
||||
# group (not just require_mention groups) so the self-mention is
|
||||
# cleaned wherever it appears.
|
||||
if is_group and text:
|
||||
account_norm = self._account_normalized
|
||||
if account_norm:
|
||||
text = text.replace(f"@{account_norm}", "")
|
||||
# Also strip if the mention was rendered using the bot's UUID
|
||||
bot_uuid = self._recipient_uuid_by_number.get(account_norm)
|
||||
if bot_uuid:
|
||||
text = text.replace(f"@{bot_uuid}", "")
|
||||
# Tidy the spacing the removed mention left behind: collapse the
|
||||
# double-space at a mid-sentence removal and trim the ends.
|
||||
# Only touches the doubled space the removal introduced, so
|
||||
# intentional newlines in a multi-line message are preserved.
|
||||
text = text.replace(" ", " ").strip()
|
||||
|
||||
# Extract quote (reply-to) context from Signal dataMessage. Signal's
|
||||
# quote.id is the timestamp of the quoted message; quote.author points
|
||||
# at the quoted sender when available. Preserve both so the gateway can
|
||||
|
|
@ -1460,8 +1481,29 @@ class SignalAdapter(BasePlatformAdapter):
|
|||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
# Reset per-chat typing backoff state so the next agent turn starts
|
||||
# fresh rather than inheriting a cooldown from a prior conversation.
|
||||
|
||||
# Send an explicit stop-typing RPC so the recipient's device drops the
|
||||
# indicator immediately instead of waiting for Signal's ~5s built-in
|
||||
# timeout. Failures are best-effort — the backoff state must still be
|
||||
# cleared so the next agent turn starts clean.
|
||||
try:
|
||||
params: Dict[str, Any] = {"account": self.account}
|
||||
if chat_id.startswith("group:"):
|
||||
params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
params["recipient"] = [await self._resolve_recipient(chat_id)]
|
||||
params["stop"] = True
|
||||
await self._rpc(
|
||||
"sendTyping",
|
||||
params,
|
||||
rpc_id="typing-stop",
|
||||
log_failures=False,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort: any RPC failure (or recipient-resolution failure)
|
||||
# must not prevent backoff cleanup.
|
||||
pass
|
||||
|
||||
self._typing_failures.pop(chat_id, None)
|
||||
self._typing_skip_until.pop(chat_id, None)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
|||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"rratmansky@gmail.com": "rratmansky",
|
||||
"lkz-de@users.noreply.github.com": "lkz-de",
|
||||
"charles@salesondemand.io": "salesondemandio",
|
||||
"victor@rocketfueldev.com": "victor-kyriazakos",
|
||||
|
|
|
|||
|
|
@ -1353,6 +1353,116 @@ class TestSignalTypingBackoff:
|
|||
assert "+155****4567" not in adapter._typing_skip_until
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _stop_typing_indicator sends explicit sendTyping(stop=True) RPC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalStopTypingExplicitRPC:
|
||||
"""Cancelling the typing indicator must issue an explicit
|
||||
sendTyping(stop=True) RPC so the recipient's device drops the indicator
|
||||
immediately, instead of waiting for Signal's built-in ~5s timeout.
|
||||
|
||||
The stop RPC is best-effort: any failure must not prevent the per-chat
|
||||
backoff state from being cleared.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_indicator_sends_stop_rpc_for_dm(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._resolve_recipient = AsyncMock(return_value="uuid-recipient")
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params), "rpc_id": rpc_id})
|
||||
return {}
|
||||
|
||||
adapter._rpc = mock_rpc
|
||||
|
||||
await adapter._stop_typing_indicator("+15555550000")
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["method"] == "sendTyping"
|
||||
assert captured[0]["params"]["stop"] is True
|
||||
assert captured[0]["params"]["recipient"] == ["uuid-recipient"]
|
||||
assert captured[0]["rpc_id"] == "typing-stop"
|
||||
adapter._resolve_recipient.assert_awaited_once_with("+15555550000")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_indicator_sends_stop_rpc_for_group(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params), "rpc_id": rpc_id})
|
||||
return {}
|
||||
|
||||
adapter._rpc = mock_rpc
|
||||
|
||||
await adapter._stop_typing_indicator("group:group123")
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["method"] == "sendTyping"
|
||||
assert captured[0]["params"]["stop"] is True
|
||||
assert captured[0]["params"]["groupId"] == "group123"
|
||||
assert "recipient" not in captured[0]["params"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_indicator_best_effort_on_rpc_failure(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._resolve_recipient = AsyncMock(return_value="uuid-recipient")
|
||||
|
||||
# Drive the chat into backoff so we can confirm cleanup still happens
|
||||
# even when the stop RPC itself fails.
|
||||
async def _noop(method, params, rpc_id=None, **kwargs):
|
||||
return None
|
||||
|
||||
adapter._rpc = _noop
|
||||
for _ in range(3):
|
||||
await adapter.send_typing("+155****0000")
|
||||
|
||||
assert adapter._typing_failures.get("+155****0000") == 3
|
||||
assert "+155****0000" in adapter._typing_skip_until
|
||||
|
||||
# Now make the stop RPC raise — backoff state must still be cleared.
|
||||
async def failing_rpc(method, params, rpc_id=None, **kwargs):
|
||||
raise RuntimeError("signal-cli unreachable")
|
||||
|
||||
adapter._rpc = failing_rpc
|
||||
|
||||
await adapter._stop_typing_indicator("+155****0000")
|
||||
|
||||
assert "+155****0000" not in adapter._typing_failures
|
||||
assert "+155****0000" not in adapter._typing_skip_until
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_indicator_best_effort_on_recipient_failure(self, monkeypatch):
|
||||
# When _resolve_recipient() raises, the per-chat backoff state must
|
||||
# still be cleared — otherwise a transient resolution failure would
|
||||
# silently keep the chat in cooldown forever.
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._resolve_recipient = AsyncMock(
|
||||
side_effect=RuntimeError("recipient resolution failed")
|
||||
)
|
||||
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None, **kwargs):
|
||||
captured.append({"method": method, "params": dict(params), "rpc_id": rpc_id})
|
||||
return {}
|
||||
|
||||
adapter._rpc = mock_rpc
|
||||
|
||||
adapter._typing_failures["+155****0000"] = 2
|
||||
adapter._typing_skip_until["+155****0000"] = 9999999999.0
|
||||
|
||||
await adapter._stop_typing_indicator("+155****0000")
|
||||
|
||||
# No RPC must be issued when recipient resolution itself fails.
|
||||
assert captured == []
|
||||
assert "+155****0000" not in adapter._typing_failures
|
||||
assert "+155****0000" not in adapter._typing_skip_until
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply quote extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue