mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
feat(agent): core affection reaction detector + reaction_callback
Add a token-free, curated affection matcher (agent/reactions.py) — the single source of truth for detecting user "vibes" (ily / <3 / good bot / heart emoji). No model call, no tokens. Generalized to return a reaction *kind* so future reactions can ride the same signal. Wire an opt-in AIAgent.reaction_callback that fires from build_turn_context on the incoming user message. It never touches the conversation (cache-safe) and never fatal — a purely cosmetic side-beat each host can consume.
This commit is contained in:
parent
caf557be5b
commit
422d9da9bd
5 changed files with 130 additions and 0 deletions
|
|
@ -302,6 +302,7 @@ def init_agent(
|
|||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -535,6 +536,7 @@ def init_agent(
|
|||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.event_callback = event_callback
|
||||
agent.reaction_callback = reaction_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
|
|
|
|||
56
agent/reactions.py
Normal file
56
agent/reactions.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Token-free detection of user *reactions* to the agent.
|
||||
|
||||
Currently the only reaction is ``vibe`` — an expression of affection or
|
||||
gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart
|
||||
emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**.
|
||||
|
||||
This is the single source of truth shared by every surface — the CLI pet, the
|
||||
TUI heart, and the desktop floating hearts all react off the same signal,
|
||||
delivered via ``AIAgent.reaction_callback`` (wired per interactive host).
|
||||
|
||||
Generalized on purpose: :func:`detect_reaction` returns a reaction *kind*
|
||||
string, so new kinds (other emoji reactions, etc.) can be added here without
|
||||
touching any caller. We match affection specifically — not general positive
|
||||
sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: The affection/gratitude reaction — the only kind today.
|
||||
VIBE = "vibe"
|
||||
|
||||
# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at
|
||||
# the agent, heart emoji, and ``<3`` (but not the broken heart ``</3``).
|
||||
_VIBE_RE = re.compile(
|
||||
"|".join(
|
||||
(
|
||||
r"\bgood\s*bot\b",
|
||||
r"\bi\s*(?:love|luv)\s*(?:you|u|ya)\b",
|
||||
r"\b(?:love|luv)\s*(?:you|u|ya)\b",
|
||||
r"\bily(?:sm)?\b",
|
||||
r"\bthank\s*(?:you|u)\b",
|
||||
r"\b(?:thanks|thx|tysm|ty)\b",
|
||||
r"<3+", # <3, <33 … but not </3
|
||||
# Hearts + affection faces (❤ ♥ 🥰 😍 😘 💕 💖 💗 💞 💛 💜 💚 💙 💓 💘 💝 🩷).
|
||||
r"[\u2764\u2665"
|
||||
r"\U0001F970\U0001F60D\U0001F618"
|
||||
r"\U0001F495\U0001F496\U0001F497\U0001F49E"
|
||||
r"\U0001F49B\U0001F49C\U0001F49A\U0001F499"
|
||||
r"\U0001F493\U0001F498\U0001F49D\U0001FA77]",
|
||||
)
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def detect_reaction(text: str | None) -> str | None:
|
||||
"""Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``.
|
||||
|
||||
Pure, token-free, and safe to call on every user turn.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
return VIBE if _VIBE_RE.search(text) else None
|
||||
|
|
@ -319,6 +319,20 @@ def build_turn_context(
|
|||
current_turn_user_idx = len(messages) - 1
|
||||
agent._persist_user_message_idx = current_turn_user_idx
|
||||
|
||||
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
|
||||
# and notify the host so it can play hearts. Token-free, never touches the
|
||||
# conversation, and never fatal — a purely optional UI beat.
|
||||
reaction_callback = getattr(agent, "reaction_callback", None)
|
||||
if reaction_callback is not None:
|
||||
try:
|
||||
from agent.reactions import detect_reaction
|
||||
|
||||
kind = detect_reaction(original_user_message)
|
||||
if kind:
|
||||
reaction_callback(kind)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not agent.quiet_mode:
|
||||
_print_preview = summarize_user_message_for_log(user_message)
|
||||
agent._safe_print(
|
||||
|
|
|
|||
|
|
@ -458,6 +458,7 @@ class AIAgent:
|
|||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -533,6 +534,7 @@ class AIAgent:
|
|||
notice_callback=notice_callback,
|
||||
notice_clear_callback=notice_clear_callback,
|
||||
event_callback=event_callback,
|
||||
reaction_callback=reaction_callback,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=reasoning_config,
|
||||
service_tier=service_tier,
|
||||
|
|
|
|||
56
tests/agent/test_reactions.py
Normal file
56
tests/agent/test_reactions.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Behavior tests for the token-free reaction detector."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.reactions import VIBE, detect_reaction
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"good bot",
|
||||
"Good Bot!",
|
||||
"ily",
|
||||
"ilysm",
|
||||
"i love you",
|
||||
"love you",
|
||||
"love u",
|
||||
"luv ya",
|
||||
"thanks",
|
||||
"thank you",
|
||||
"thx",
|
||||
"ty",
|
||||
"tysm",
|
||||
"you're the best <3",
|
||||
"here you go <33",
|
||||
"❤️",
|
||||
"🥰 amazing",
|
||||
"sending 💖",
|
||||
"great job, thank you so much!",
|
||||
],
|
||||
)
|
||||
def test_affection_fires_vibe(text):
|
||||
assert detect_reaction(text) == VIBE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"",
|
||||
None,
|
||||
"run the tests",
|
||||
"this is great", # positive sentiment, NOT affection — must not fire
|
||||
"awesome work on the refactor",
|
||||
"it's broken </3", # broken heart is not affection
|
||||
"the ferry departs at 3", # 'ty' must be word-bounded, not match 'ferTY'-like
|
||||
"commit the changes",
|
||||
],
|
||||
)
|
||||
def test_neutral_or_negative_does_not_fire(text):
|
||||
assert detect_reaction(text) is None
|
||||
|
||||
|
||||
def test_case_insensitive_invariant():
|
||||
# Casing must never change the classification.
|
||||
for text in ("ILY", "iLy", "ily"):
|
||||
assert detect_reaction(text) == detect_reaction("ily")
|
||||
Loading…
Add table
Add a link
Reference in a new issue