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:
Brooklyn Nicholson 2026-07-10 05:41:59 -05:00
parent caf557be5b
commit 422d9da9bd
5 changed files with 130 additions and 0 deletions

View file

@ -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
View 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

View file

@ -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(

View file

@ -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,

View 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")