From 9632264e0ef399e9fab51ec479edb17f8251f268 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 2 Aug 2026 13:47:20 +0200 Subject: [PATCH] feat(agent): introduce itself and the room's topic when invited Being added to a room is when a family is actually looking at the agent and wondering what it does. It joined in silence, so the answer was nothing. On an invite it now takes one ordinary turn. In a topic room: who it is, what the topic is about, how many todos are open and one or two of them, and that it can add or tick items off. Anywhere else, a DM included: who it is and what it can actually do. The topic is resolved before the turn rather than guessed during it, because asked to describe a topic that does not exist the model says "let me check what this room is about" and then stops, which is a worse first impression than staying quiet. The overview already exists as prose in the brain projection and the per-turn briefing already names the room's topic, so nothing new is fetched and nothing is recited from a stale snapshot. --- stacklets/agent/runtime/brief.py | 16 +++- stacklets/agent/runtime/join_greeting.py | 84 +++++++++++++++++++ stacklets/agent/runtime/sitecustomize.py | 58 +++++++++++++ tests/stacklets/conftest.py | 12 +++ tests/stacklets/test_agent_join_greeting.py | 93 +++++++++++++++++++++ tests/stacklets/test_agent_runtime_shims.py | 30 ++++++- 6 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 stacklets/agent/runtime/join_greeting.py create mode 100644 tests/stacklets/test_agent_join_greeting.py diff --git a/stacklets/agent/runtime/brief.py b/stacklets/agent/runtime/brief.py index 04e16f21..1d7b630c 100644 --- a/stacklets/agent/runtime/brief.py +++ b/stacklets/agent/runtime/brief.py @@ -89,14 +89,26 @@ def _slug_candidates(raw: str) -> list[str]: return [base] if base else [] -def _topic_slug(msg, vault: Path) -> str: - raw = (getattr(msg, "metadata", {}) or {}).get("room", "") +def topic_for_room_label(raw: str, vault: Path) -> str: + """The vault topic a room label maps to, or "" when it maps to none. + + Exposed (rather than folded into `_topic_slug`) because joining a + room needs the same answer before there is any message to derive it + from: a room with a topic gets a different greeting from a DM, and + guessing wrong produces a bot that promises to describe a topic + that does not exist. + """ for cand in _slug_candidates(raw): if (vault / "family" / cand / "about.md").exists(): return cand return "" +def _topic_slug(msg, vault: Path) -> str: + raw = (getattr(msg, "metadata", {}) or {}).get("room", "") + return topic_for_room_label(raw, vault) + + def brief_lines(msg, workspace) -> list[str]: """Return the briefing as a list of short runtime-context lines (may be empty).""" vault = Path(workspace) / "vault" diff --git a/stacklets/agent/runtime/join_greeting.py b/stacklets/agent/runtime/join_greeting.py new file mode 100644 index 00000000..ef919038 --- /dev/null +++ b/stacklets/agent/runtime/join_greeting.py @@ -0,0 +1,84 @@ +"""What the agent says the moment it is invited into a room. + +Being added to a room is the one moment a family is actually looking at +the agent and wondering what it is for. nanobot's stock invite handler +joins and says nothing, so the answer they get is silence, and the next +question is "is it broken?". + +This module holds the *prompt* half of the join greeting: the turn the +agent is asked to take once it has joined. The wiring lives in +`sitecustomize.py`; keeping the words here means they can be read and +changed without touching a monkeypatch, and asserted in a unit test. + +WHY A GENERATED GREETING AND NOT A CANNED ONE + +The archivist's welcome is a fixed block of text, and rightly so: it +explains a fixed set of commands. This one has to say what *this room's +topic* is about, which is different in every room and already written +down in the topic's `about.md`. A canned string cannot do that, and +hand-rolling a summariser next to an agent that summarises for a living +would be the wrong kind of simple. + +So the agent takes an ordinary turn. It already has the brain +projection mounted and the per-turn briefing naming the room's topic +(see `brief.py`), which means the greeting is composed the same way +every other answer is, with no second retrieval path to keep correct. + +WHAT IT MUST NOT DO + +Recite. The topic page and the todo list both go stale, and a greeting +that pastes today's list is wrong by tomorrow and still sitting in the +timeline. It reads the live page at greeting time and points at the +commands for the rest, which is the same pointers-not-dumps rule the +briefing follows. +""" + +from __future__ import annotations + +# Framed as an instruction to the agent, not as something a person said, +# because it is injected into the turn loop rather than posted to the +# room. Only the agent's reply is visible to the family. +_PREAMBLE = ( + "[You have just been invited into this room and have joined it. " + "No one has spoken to you yet.]\n\n" +) + +# Shared across both greetings. The "look first, then write once" rule is +# not style: the model otherwise posts "let me check what this room is +# about" as a message of its own and then either repeats itself or, in a +# room with nothing to look up, never speaks again. Both were observed. +_STYLE = ( + "\n\nLook anything up BEFORE you write, and then send exactly one " + "message. Do not announce what you are about to do, and do not " + "narrate your own tool use. Keep it to a few lines. No headings, no " + "bullet list longer than two items. Do not end with an offer of " + "further help or a sign-off. Sound like a person joining a " + "conversation, not a manual." +) + +_TOPIC_GREETING = ( + "Introduce yourself in one short line, then say what this room's " + "topic '{topic}' is about, in your own words, from its page in the " + "vault. If it has open todos, say how many and name one or two, then " + "say you can add items, tick them off, or change them." +) + +_PLAIN_GREETING = ( + "This room has no topic page, so do not describe one and do not go " + "looking for it. Introduce yourself in one short line and say " + "plainly what you can do: answer questions from the family's own " + "notes and documents, and keep topic todo lists." +) + + +def greeting_prompt(topic: str = "") -> str: + """The turn the agent takes on joining a room. + + `topic` is the vault slug the room maps to, or "" for a DM or any + other room that has no topic page. The distinction matters more than + it looks: asked to describe a topic that does not exist, the model + says "let me check what this room is about" and then stops, which is + a worse first impression than no greeting at all. + """ + body = _TOPIC_GREETING.format(topic=topic) if topic else _PLAIN_GREETING + return _PREAMBLE + body + _STYLE diff --git a/stacklets/agent/runtime/sitecustomize.py b/stacklets/agent/runtime/sitecustomize.py index a3af095a..c7b7199b 100644 --- a/stacklets/agent/runtime/sitecustomize.py +++ b/stacklets/agent/runtime/sitecustomize.py @@ -34,6 +34,9 @@ agent by its configured name counts as a mention, not just an autocompleted pill. Families type "Stacky, what's on our list?". +7. join_greeting (join_greeting.py) — on being invited, take one turn and + introduce the room's topic instead of joining in silence. + 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 @@ -55,6 +58,8 @@ 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` + join_greeting: `nanobot.channels.matrix.MatrixChannel._on_room_invite(self, room, event)` + `MatrixChannel._handle_message(sender_id, chat_id, content, metadata, is_dm)` `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 @@ -178,3 +183,56 @@ def _is_bot_mentioned(self, event): _log.info("name-trigger mention shim active") except Exception: _log.exception("name-trigger shim could not attach (nanobot internals changed?)") + + +# ── join_greeting: say something useful the moment you are invited ─────────── +# Stock nanobot joins an invite silently. In a topic room that silence is the +# family's first impression of the agent, so it takes one ordinary turn instead +# (see join_greeting.py for why generated rather than canned). +try: + import asyncio as _asyncio + import os.path as _ospath + from pathlib import Path as _Path + + import nanobot.channels.matrix as _matrix_join + from brief import topic_for_room_label as _topic_for_room_label + from join_greeting import greeting_prompt as _greeting_prompt + + # Same workspace nanobot mounts the projection into; `lean_state` + # above resolves its log the same way. + _WORKSPACE = _Path(_ospath.expanduser("~/.nanobot/workspace")) + + _orig_on_room_invite = _matrix_join.MatrixChannel._on_room_invite + + async def _on_room_invite(self, room, event): + await _orig_on_room_invite(self, room, event) + try: + # The room's name arrives with the state sync that follows the + # join, not with the invite. Greeting before it lands would cost + # the briefing its topic line — the whole point of greeting at + # all — so wait briefly for a display name to appear. + label = "" + for _ in range(10): + joined = (getattr(self.client, "rooms", {}) or {}).get(room.room_id) + label = getattr(joined, "display_name", "") or "" + if label and label != room.room_id: + break + await _asyncio.sleep(1) + + topic = _topic_for_room_label(label, _WORKSPACE / "vault") + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content=_greeting_prompt(topic), + metadata={"room": label or getattr(room, "room_id", "")}, + is_dm=False, + ) + except Exception: + # A missing greeting is a disappointment; a raised exception in + # the invite callback would leave the bot joined and deaf. + _log.exception("join greeting failed; the room is still joined") + + _matrix_join.MatrixChannel._on_room_invite = _on_room_invite + _log.info("join-greeting shim active") +except Exception: + _log.exception("join-greeting shim could not attach (nanobot internals changed?)") diff --git a/tests/stacklets/conftest.py b/tests/stacklets/conftest.py index 9e382258..f4215b58 100644 --- a/tests/stacklets/conftest.py +++ b/tests/stacklets/conftest.py @@ -78,10 +78,22 @@ async def execute(self, *args, **kwargs): return "stock grep" class MatrixChannel: + def __init__(self): + self.client = types.SimpleNamespace(rooms={}) + self.handled = [] + self.joined = [] + def _is_bot_mentioned(self, event): # Stock nanobot: only an autocompleted pill counts. return getattr(event, "pill_mention", False) + async def _on_room_invite(self, room, event): + # Stock nanobot: join, say nothing. + self.joined.append(room.room_id) + + async def _handle_message(self, **kwargs): + self.handled.append(kwargs) + mods: dict[str, types.ModuleType] = {} def mod(name, **attrs): diff --git a/tests/stacklets/test_agent_join_greeting.py b/tests/stacklets/test_agent_join_greeting.py new file mode 100644 index 00000000..a72efa02 --- /dev/null +++ b/tests/stacklets/test_agent_join_greeting.py @@ -0,0 +1,93 @@ +"""What the agent is asked to say when it joins a room. + +A greeting is the first thing a family ever sees the agent do, so the +cases here are about first impressions rather than mechanics: the room +with a topic, and the room without one. The second is the one that bit +us. Asked to describe a topic that does not exist, the model answered +"let me check what this room is about" and then never spoke again, which +reads as a broken bot rather than a quiet one. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) + +from brief import topic_for_room_label # noqa: E402 +from join_greeting import greeting_prompt # noqa: E402 + + +class TestARoomWithATopic: + + def test_the_greeting_names_that_topic(self): + prompt = greeting_prompt("camping") + assert "camping" in prompt + + def test_it_is_asked_for_the_todo_state(self): + """The count and an item or two are the useful part of a topic + greeting; without them it is just an introduction.""" + prompt = greeting_prompt("camping").lower() + assert "todo" in prompt + + +class TestARoomWithoutATopic: + """A DM, or any room whose name maps to no topic page.""" + + def test_it_is_told_not_to_describe_a_topic(self): + prompt = greeting_prompt("").lower() + assert "no topic page" in prompt + + def test_it_is_told_what_to_say_instead(self): + prompt = greeting_prompt("").lower() + assert "introduce yourself" in prompt + + def test_no_topic_slug_leaks_into_the_prompt(self): + """Guards the branch: the topic wording must be gone entirely, + not merely have an empty slug interpolated into it.""" + assert "this room's topic" not in greeting_prompt("").lower() + + +class TestBothGreetingsShareTheHouseStyle: + + def test_the_model_is_told_to_look_first_and_write_once(self): + """The double-post fix. + + The model narrated "let me check how many there are" as its own + message, then answered in a second one, so the family saw the + agent introduce itself twice. + """ + for prompt in (greeting_prompt("camping"), greeting_prompt("")): + low = prompt.lower() + assert "before you write" in low + assert "exactly one message" in low + + def test_no_customer_service_sign_off(self): + """"Let me know if you'd like help with anything else!" is the + register the project's voice rules exist to prevent.""" + for prompt in (greeting_prompt("camping"), greeting_prompt("")): + assert "sign-off" in prompt.lower() + + +class TestResolvingARoomToItsTopic: + """The lookup that decides which of the two greetings is used.""" + + def test_a_topic_room_resolves_when_its_page_exists(self, tmp_path): + page = tmp_path / "family" / "camping" / "about.md" + page.parent.mkdir(parents=True) + page.write_text("# Camping", encoding="utf-8") + + assert topic_for_room_label("Topic: Camping", tmp_path) == "camping" + + def test_a_topic_room_with_no_page_yet_resolves_to_nothing(self, tmp_path): + """Pages are generated, so a brand-new topic room has none. + + It must fall back to the plain greeting rather than promise a + description of a page that has not been written. + """ + assert topic_for_room_label("Topic: Camping", tmp_path) == "" + + def test_a_dm_resolves_to_nothing(self, tmp_path): + assert topic_for_room_label("Bart ⇄ Stacky", tmp_path) == "" diff --git a/tests/stacklets/test_agent_runtime_shims.py b/tests/stacklets/test_agent_runtime_shims.py index ed5bbbd9..e4a03585 100644 --- a/tests/stacklets/test_agent_runtime_shims.py +++ b/tests/stacklets/test_agent_runtime_shims.py @@ -24,7 +24,8 @@ import pytest SHIMMED_MODULES = ("sitecustomize", "brief", "lean_state", - "memory_tool", "person_tool", "grep_tool", "name_trigger") + "memory_tool", "person_tool", "grep_tool", "name_trigger", + "join_greeting") # The stub nanobot itself lives in conftest as `nanobot_stub`, shared with @@ -127,6 +128,33 @@ class _Event: assert channel._is_bot_mentioned(_Event()) +def test_an_invite_produces_a_greeting_turn(nanobot): + """Joining in silence is the behaviour this replaces. + + Asserts the agent is actually driven — the room is joined *and* a + turn is taken for it — because a shim that joined and then dropped + the turn would look identical from the room's side to stock nanobot. + """ + import asyncio + import types as _types + + mods = nanobot() + channel = mods["nanobot.channels.matrix"].MatrixChannel() + room = _types.SimpleNamespace(room_id="!r:simpson", display_name="Topic: Camping") + channel.client.rooms = {"!r:simpson": room} + event = _types.SimpleNamespace(sender="@homer:simpson") + + asyncio.run(channel._on_room_invite(room, event)) + + assert channel.joined == ["!r:simpson"], "the original join must still happen" + assert len(channel.handled) == 1, "the invite should drive exactly one turn" + turn = channel.handled[0] + assert turn["chat_id"] == "!r:simpson" + # The briefing resolves the topic from this label, so a greeting that + # loses it cannot mention what the room is about — the whole point. + assert turn["metadata"]["room"] == "Topic: Camping" + + # ── failure is contained, and visible ──────────────────────────────────── def test_a_moved_symbol_does_not_take_the_others_down(nanobot):