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):