diff --git a/stacklets/agent/runtime/brief.py b/stacklets/agent/runtime/brief.py index 04e16f2..1d7b630 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 0000000..ef91903 --- /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 a3af095..c7b7199 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 9e38225..f4215b5 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 0000000..a72efa0 --- /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 ed5bbbd..e4a0358 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):