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
16 changes: 14 additions & 2 deletions stacklets/agent/runtime/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
84 changes: 84 additions & 0 deletions stacklets/agent/runtime/join_greeting.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions stacklets/agent/runtime/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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?)")
12 changes: 12 additions & 0 deletions tests/stacklets/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
93 changes: 93 additions & 0 deletions tests/stacklets/test_agent_join_greeting.py
Original file line number Diff line number Diff line change
@@ -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) == ""
30 changes: 29 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,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
Expand Down Expand Up @@ -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):
Expand Down
Loading