Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions stacklets/agent/runtime/name_trigger.py
Original file line number Diff line number Diff line change
@@ -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))
39 changes: 39 additions & 0 deletions stacklets/agent/runtime/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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?)")
7 changes: 7 additions & 0 deletions tests/stacklets/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
124 changes: 124 additions & 0 deletions tests/stacklets/test_agent_name_trigger.py
Original file line number Diff line number Diff line change
@@ -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")
37 changes: 36 additions & 1 deletion tests/stacklets/test_agent_runtime_shims.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading