From dabfb3c7f47ffad9f7ad5c966c2071ef41ca1fbe Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 2 Aug 2026 12:53:53 +0200 Subject: [PATCH] feat(agent): answer when the family says its name, not just @-mentions In a group room the agent only replied to an autocompleted Matrix pill, so "Stacky, what's on our list?" was dropped before it saw it. That is the way everyone who does not know what a pill is talks to it. Being addressed by the configured name now counts as a mention: at the start of a message, or tacked on the end, case-insensitively, and after a greeting. A name in the middle of a sentence does not, so the agent stays out of conversations about it. Replies are handled too, since Matrix puts the quoted message first in the body. The name comes from AGENT_NAME, so renaming the agent to "Kit" makes it answer to "kit, whats on the list" with no code change. --- stacklets/agent/runtime/name_trigger.py | 113 ++++++++++++++++++ stacklets/agent/runtime/sitecustomize.py | 39 ++++++ tests/stacklets/conftest.py | 7 ++ tests/stacklets/test_agent_name_trigger.py | 124 ++++++++++++++++++++ tests/stacklets/test_agent_runtime_shims.py | 37 +++++- 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 stacklets/agent/runtime/name_trigger.py create mode 100644 tests/stacklets/test_agent_name_trigger.py diff --git a/stacklets/agent/runtime/name_trigger.py b/stacklets/agent/runtime/name_trigger.py new file mode 100644 index 0000000..35a5e42 --- /dev/null +++ b/stacklets/agent/runtime/name_trigger.py @@ -0,0 +1,113 @@ +"""Does this message address the agent by name? + +nanobot's group policy answers "is the bot mentioned?" by reading the +`m.mentions` payload, which only exists when the sender picked the bot +out of an autocomplete list. That is a fine rule for Slack and a poor +one for a family room, where people type what they would say out loud: + + Stacky, what's on our list? + +No pill, no `m.mentions`, no reply. This module supplies the missing +half of the question, and nothing else -- it is pure text in, bool out, +so the rule can be argued with in tests rather than in a running +container. + +WHAT COUNTS AS BEING ADDRESSED + +Only the vocative: the name at the start of the message, or tacked on +at the end. Both are how someone speaks to the agent. + + Stacky, what's on our list? addressed + stacky whats on our list addressed (case, punctuation) + hey Stacky can you strike item 3 addressed (greeting first) + what's on our list, Stacky? addressed (trailing vocative) + +A name in the middle of a sentence is someone *talking about* the +agent, not to it, and is deliberately ignored: + + I asked Stacky and it said no not addressed + we should get Stacky to do this not addressed + +THE ONE CASE THIS GETS WRONG, KNOWINGLY + +A sentence that opens with the name in the third person reads as an +address by this rule: + + Stacky got that wrong yesterday addressed (false positive) + +The alternative is to demand punctuation after the name, which would +drop "Stacky whats on our list" -- the single most likely thing anyone +types. One unwanted reply is a smaller failure than a bot that ignores +the plainest way of asking it something, so the tradeoff is taken +deliberately in that direction. + +THE NAME IS CONFIGURED, NOT ASSUMED + +`AGENT_NAME` is a family's choice (`{agent_name}` in stack.toml, see +the agent stacklet's manifest), so the name is a parameter here and the +comparison is case-insensitive. Renaming the agent to "Kit" must make +"kit, whats on the list" work with no code change. An empty or +whitespace name matches nothing at all, which matters: a blank pattern +would otherwise make every message in every room an address. +""" + +from __future__ import annotations + +import re +from functools import lru_cache + +# Openers people put before a name. Kept short on purpose: each one is a +# word that could begin a sentence, and the name still has to follow it. +_GREETINGS = r"(?:hey|hi|hello|yo|ok|okay)" + + +@lru_cache(maxsize=8) +def _pattern(name: str) -> re.Pattern[str]: + """Compile the address pattern for one name. + + Cached because this runs on every group-room message that was not + already a pill mention, and the name changes about once ever. + """ + n = re.escape(name) + # `(?!\w)` rather than `\b` for the end of the name: `\b` needs a + # word character on one side, so a name ending in punctuation (an + # emoji-ish handle, "Mr. Bot") would never satisfy it and the agent + # would answer to nothing at all. + return re.compile( + # Leading address, optionally after a greeting: "Stacky, ..." + rf"^\W*(?:{_GREETINGS}\W+)?{n}(?!\w)" + # Trailing vocative, set off by a comma: "..., Stacky?" + rf"|[,;]\s*{n}(?!\w)\W*$", + re.IGNORECASE, + ) + + +def strip_reply_fallback(body: str) -> str: + """Drop the quoted block Matrix prepends to a plain-text reply. + + A reply's `body` carries the message it answers as `> ` lines before + the actual text. Without stripping them the sender's own words are + never at the start of the body, so "Stacky, do X" sent as a reply + would not read as an address -- which is exactly how someone follows + up in a busy room. + """ + lines = (body or "").splitlines() + index = 0 + while index < len(lines) and lines[index].startswith(">"): + index += 1 + if not index: + return body or "" + while index < len(lines) and not lines[index].strip(): + index += 1 + return "\n".join(lines[index:]) + + +def addressed_by_name(body: str, name: str) -> bool: + """True when `body` speaks to `name` rather than merely mentioning it.""" + name = (name or "").strip() + if not name: + return False + text = strip_reply_fallback(body).strip() + if not text: + return False + return bool(_pattern(name).search(text)) diff --git a/stacklets/agent/runtime/sitecustomize.py b/stacklets/agent/runtime/sitecustomize.py index 462c140..a3af095 100644 --- a/stacklets/agent/runtime/sitecustomize.py +++ b/stacklets/agent/runtime/sitecustomize.py @@ -28,6 +28,12 @@ the agent gets semantic hits instead of literal matches on a corpus where the words it greps for are rarely the words on disk. +Third, one shim that widens when the agent is allowed to answer at all: + +6. name_trigger (name_trigger.py) — a group-room message that addresses the + agent by its configured name counts as a mention, not just an autocompleted + pill. Families type "Stacky, what's on our list?". + WHY SHIMS AND NOT A FORK nanobot has no plugin seam for per-turn context injection or state shaping. Shims keep us on upstream `nanobot-ai` (updates included) with the change @@ -48,6 +54,7 @@ `nanobot.agent.tools.schema.{StringSchema, IntegerSchema, tool_parameters_schema}` person_tool: same symbols as memory_tool grep_tool: `nanobot.agent.tools.search.GrepTool.execute(...) -> str` + name_trigger: `nanobot.channels.matrix.MatrixChannel._is_bot_mentioned(self, event) -> bool` `tests/stacklets/test_agent_runtime_shims.py` asserts every one of these is attached against a stub nanobot, so this list is executable rather than @@ -139,3 +146,35 @@ def _build_messages_lean(self, *args, **kwargs): _log.info("%s active", _what) except Exception: _log.exception("%s could not attach (nanobot internals changed?)", _what) + + +# ── name_trigger: being spoken to by name counts as a mention ──────────────── +# Widens nanobot's group-room gate rather than replacing it: a real pill mention +# still wins on the original code path, and this only gets a say when that said +# no. `AGENT_NAME` is read per call, so renaming the agent takes effect on the +# next restart with no rebuild. +try: + import os as _os + + import nanobot.channels.matrix as _matrix + from name_trigger import addressed_by_name as _addressed_by_name + + _orig_is_bot_mentioned = _matrix.MatrixChannel._is_bot_mentioned + + def _is_bot_mentioned(self, event): + if _orig_is_bot_mentioned(self, event): + return True + try: + return _addressed_by_name( + getattr(event, "body", "") or "", _os.environ.get("AGENT_NAME", ""), + ) + except Exception: + # Never let a matching bug make the agent unreachable: fall back + # to stock behaviour, which is pill mentions only. + _log.exception("name trigger failed; pill mentions still work") + return False + + _matrix.MatrixChannel._is_bot_mentioned = _is_bot_mentioned + _log.info("name-trigger mention shim active") +except Exception: + _log.exception("name-trigger shim could not attach (nanobot internals changed?)") diff --git a/tests/stacklets/conftest.py b/tests/stacklets/conftest.py index 95542cd..9e38225 100644 --- a/tests/stacklets/conftest.py +++ b/tests/stacklets/conftest.py @@ -77,6 +77,11 @@ class GrepTool: async def execute(self, *args, **kwargs): return "stock grep" + class MatrixChannel: + def _is_bot_mentioned(self, event): + # Stock nanobot: only an autocompleted pill counts. + return getattr(event, "pill_mention", False) + mods: dict[str, types.ModuleType] = {} def mod(name, **attrs): @@ -97,6 +102,8 @@ def mod(name, **attrs): tool_parameters_schema=tool_parameters_schema) mod("nanobot.agent.tools.loader", ToolLoader=ToolLoader) mod("nanobot.agent.tools.search", GrepTool=GrepTool) + mod("nanobot.channels") + mod("nanobot.channels.matrix", MatrixChannel=MatrixChannel) return mods return _build diff --git a/tests/stacklets/test_agent_name_trigger.py b/tests/stacklets/test_agent_name_trigger.py new file mode 100644 index 0000000..2908ea0 --- /dev/null +++ b/tests/stacklets/test_agent_name_trigger.py @@ -0,0 +1,124 @@ +"""When does a family-room message count as talking to the agent? + +The rule these tests pin is a product decision, not an implementation +detail: people in a family room type "Stacky, what's on our list?" +rather than autocompleting a Matrix pill, and an agent that answers +only pills looks broken to everyone who does not know what a pill is. + +Read this file as the spec for that rule. The cases are the sentences +a family actually sends, and the boundary they draw is between speaking +*to* the agent and speaking *about* it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) + +from name_trigger import addressed_by_name # noqa: E402 + + +class TestSpeakingToTheAgent: + + @pytest.mark.parametrize("body", [ + "Stacky, what's on our list?", + "Stacky what's on our list?", + "Stacky: what's on our list?", + "hey Stacky, can you strike item 3", + "Hey Stacky can you strike item 3", + "ok Stacky, add milk to the shopping list", + "what's on our list, Stacky?", + "can you strike the camera one, Stacky", + ]) + def test_the_agent_is_being_addressed(self, body): + assert addressed_by_name(body, "Stacky") + + def test_case_never_matters(self): + """Nobody capitalises consistently on a phone keyboard.""" + for body in ("stacky, whats on our list", "STACKY WHATS ON OUR LIST", + "StAcKy whats on our list"): + assert addressed_by_name(body, "Stacky"), body + + def test_a_reply_still_reads_as_an_address(self): + """Matrix puts the quoted message in the body before the reply. + + Following up on something with "Stacky, ..." is ordinary in a + busy room, and the sender's own words are never at position zero + when they do. + """ + body = ( + "> <@marge:simpson> shall we sort the camping trip?\n" + "\n" + "Stacky, what's on our list?" + ) + assert addressed_by_name(body, "Stacky") + + +class TestSpeakingAboutTheAgent: + """The agent must not butt into a conversation about itself.""" + + @pytest.mark.parametrize("body", [ + "I asked Stacky and it said no", + "we should get Stacky to do this", + "does anyone else find Stacky slow?", + "the Stacky thing worked well yesterday", + ]) + def test_a_mid_sentence_name_is_not_an_address(self, body): + assert not addressed_by_name(body, "Stacky") + + def test_an_unrelated_message_is_left_alone(self): + assert not addressed_by_name("what's on our list?", "Stacky") + + def test_a_longer_word_starting_with_the_name_is_not_the_name(self): + assert not addressed_by_name("Stackyish behaviour again", "Stacky") + + +class TestTheNameIsConfigured: + """`AGENT_NAME` is a family's choice, so nothing may assume "Stacky".""" + + def test_a_renamed_agent_answers_to_its_own_name(self): + assert addressed_by_name("Kit, whats on the list", "Kit") + assert addressed_by_name("kit, whats on the list", "Kit") + + def test_a_renamed_agent_stops_answering_to_the_old_one(self): + assert not addressed_by_name("Stacky, whats on the list", "Kit") + + def test_a_name_with_a_space_still_works(self): + assert addressed_by_name("family bot, whats on the list", "Family Bot") + + def test_a_name_with_regex_characters_is_matched_literally(self): + """A name is text a family typed, never a pattern. + + Unescaped, the `.` in "Mr. Bot" matches any character, so the + agent would answer to "Mr! Bot" and anything else shaped like + it. + """ + assert addressed_by_name("mr. bot, whats on the list", "Mr. Bot") + assert not addressed_by_name("mr! bot, whats on the list", "Mr. Bot") + + def test_a_name_ending_in_punctuation_still_matches(self): + """A word boundary needs a word character to sit against, which + a name like this never provides.""" + assert addressed_by_name("c++, whats on the list", "C++") + + +class TestAnUnconfiguredNameMatchesNothing: + + @pytest.mark.parametrize("name", ["", " ", None]) + def test_a_blank_name_never_triggers(self, name): + """The dangerous default. + + An empty name compiles to a pattern that matches everywhere, so + getting this wrong turns every message in every room into an + address and the agent answers all of them. + """ + assert not addressed_by_name("Stacky, what's on our list?", name) + + def test_an_empty_message_is_not_an_address(self): + assert not addressed_by_name("", "Stacky") + assert not addressed_by_name("> <@marge:simpson> quoted only\n", "Stacky") diff --git a/tests/stacklets/test_agent_runtime_shims.py b/tests/stacklets/test_agent_runtime_shims.py index 87104c4..ed5bbbd 100644 --- a/tests/stacklets/test_agent_runtime_shims.py +++ b/tests/stacklets/test_agent_runtime_shims.py @@ -24,7 +24,7 @@ import pytest SHIMMED_MODULES = ("sitecustomize", "brief", "lean_state", - "memory_tool", "person_tool", "grep_tool") + "memory_tool", "person_tool", "grep_tool", "name_trigger") # The stub nanobot itself lives in conftest as `nanobot_stub`, shared with @@ -92,6 +92,41 @@ def test_context_shims_are_attached(nanobot): assert ctx.ContextBuilder.build_messages.__name__ == "_build_messages_lean" +def test_being_named_counts_as_a_mention(nanobot, monkeypatch): + """Without this shim the agent ignores everyone who does not use a pill. + + Driven through nanobot's own gate rather than the matcher directly: + the matcher is specified in `test_agent_name_trigger.py`, and what + is at stake here is that nanobot actually asks it. + """ + monkeypatch.setenv("AGENT_NAME", "Stacky") + mods = nanobot() + channel = mods["nanobot.channels.matrix"].MatrixChannel() + + class _Event: + pill_mention = False + body = "Stacky, what's on our list?" + + assert channel._is_bot_mentioned(_Event()) + + +def test_a_pill_mention_still_wins_on_the_original_path(nanobot, monkeypatch): + """The shim widens the gate; it must never narrow it. + + A pill from someone who never says the name has to keep working + even when the name matcher would say no. + """ + monkeypatch.setenv("AGENT_NAME", "Stacky") + mods = nanobot() + channel = mods["nanobot.channels.matrix"].MatrixChannel() + + class _Event: + pill_mention = True + body = "what's on our list?" + + assert channel._is_bot_mentioned(_Event()) + + # ── failure is contained, and visible ──────────────────────────────────── def test_a_moved_symbol_does_not_take_the_others_down(nanobot):