diff --git a/stacklets/agent/runtime/list_tool.py b/stacklets/agent/runtime/list_tool.py index 8f512fd5..84a1507a 100644 --- a/stacklets/agent/runtime/list_tool.py +++ b/stacklets/agent/runtime/list_tool.py @@ -23,6 +23,49 @@ ) +class AwaitingYes: + """Lists whose delete the store refused because of open items, by turn. + + The person has to say yes, the model cannot: on the rig (2026-10-12) + the store refused a delete and the model sent `confirmed` itself in + the same turn. So a yes counts only in a later turn than the refusal, + which is a message from the person after they were told what is open. + + awaiting = AwaitingYes() + awaiting.refused("partyliste", turn="t1") + awaiting.allows("partyliste", turn="t1") # False: same turn + awaiting.allows("partyliste", turn="t2") # True + """ + + def __init__(self) -> None: + self._refused_in: dict[str, str] = {} + + def refused(self, slug: str, *, turn: str) -> None: + if turn: + self._refused_in[slug] = turn + + def allows(self, slug: str, *, turn: str) -> bool: + refused_in = self._refused_in.get(slug, "") + return bool(refused_in and turn and turn != refused_in) + + def done(self, slug: str) -> None: + self._refused_in.pop(slug, None) + + +# What the store says when a list to delete still has open items. +_NEEDS_YES = "add --yes" + +_awaiting_yes = AwaitingYes() + + +def _current_turn() -> str: + """The id of the turn this tool call belongs to, "" if nanobot has none.""" + from nanobot.agent.tools.context import current_request_context + + request = current_request_context() + return str(getattr(request, "turn_id", "") or "") + + @tool_parameters( tool_parameters_schema( list=StringSchema( @@ -134,10 +177,12 @@ async def execute( if op in ("create", "set"): args = _details_args(op, list, title, about, topic, sections) elif op == "delete": - args = ["stack", "memory", "list", "delete", (list or "").strip()] - # Only a real yes: nanobot casts "true"/"false" but passes any - # other string through, and a stray string is not a person's yes. - args += ["--yes"] if confirmed is True else [] + slug, turn = (list or "").strip(), _current_turn() + args = ["stack", "memory", "list", "delete", slug] + # Only a real yes, in a later message than the refusal: nanobot + # casts "true"/"false" but passes any other string through. + if confirmed is True and _awaiting_yes.allows(slug, turn=turn): + args.append("--yes") else: args = _item_args(op, list, items, topic, section, people, due, to) args += ["--as", actor] @@ -155,9 +200,27 @@ async def execute( # and retries once. if proc.returncode not in (0, 1): return f"Error: list edit failed with exit {proc.returncode}: {err or out}" + if op == "delete": + if _NEEDS_YES in out + err: + _awaiting_yes.refused(slug, turn=turn) + return _ask_first(out or err) + if proc.returncode == 0: + _awaiting_yes.done(slug) return out or err or "(no answer from the list store)" +def _ask_first(refusal: str) -> str: + """The store's refusal, as what the model has to do next. + + The store answers for a person at a terminal ("add --yes"). The model + read that as a step it could not take and went looking for another + way to delete the file; what it has to do is ask. + """ + what = refusal.strip().lstrip("✗ ").split(" Delete it with them")[0] + return (f"Not deleted: {what} Ask the person whether these go too, naming them. " + "If they say yes in their next message, call delete again with confirmed=true.") + + def _item_args(op, slug, items, topic, section, people, due, to) -> "list[str]": """`stack memory list [] ... [flags]` for an item operation.""" args = ["stack", "memory", "list", op] diff --git a/stacklets/agent/runtime/open_question.py b/stacklets/agent/runtime/open_question.py new file mode 100644 index 00000000..47be5dcc --- /dev/null +++ b/stacklets/agent/runtime/open_question.py @@ -0,0 +1,60 @@ +"""An answer to the agent's own question needs no name. + +The agent asks before it acts on anything unclear: two items fit what +was said, a list to delete still has open items. In a room with several +people only a message that starts with the agent's name reaches it +(`name_trigger.py`), so the natural answer, "Ja, die können weg.", went +nowhere and the conversation the agent started died silently. + +So while the agent's last message in a room is a question to someone, +that person's next message in the room counts as addressed, if it comes +within `WINDOW_SECONDS`. Only that one message: after it, the name is +needed again. A message from anyone else, or one that comes later, is +treated as before. Whatever else the agent says in the room closes the +question, since it is no longer the last thing asked. + + questions = OpenQuestions() + questions.asked("!kitchen", "@homer:simpson", at=100.0) + questions.answers("!kitchen", "@marge:simpson", at=110.0) # False + questions.answers("!kitchen", "@homer:simpson", at=120.0) # True, once +""" + +from __future__ import annotations + +import re + +# How long a question stays open: long enough to read it and type an +# answer, short enough that a message an hour later about something else +# is not taken for one. +WINDOW_SECONDS = 300 + +# The end of a message, past trailing quotes, brackets, emoji and spaces. +_TRAILING = re.compile(r"[\s\"'”“»«)\]*_~\U0001F300-\U0001FAFF☀-➿]+$") + + +def asks(text: str) -> bool: + """True when `text` ends with a question.""" + return _TRAILING.sub("", text or "").endswith("?") + + +class OpenQuestions: + """Per room: whom the agent asked, and when. Room ids and person ids as Matrix has them.""" + + def __init__(self, window: float = WINDOW_SECONDS) -> None: + self._window = window + self._open: dict[str, tuple[str, float]] = {} + + def asked(self, room: str, person: str, *, at: float) -> None: + if room and person: + self._open[room] = (person, at) + + def closed(self, room: str) -> None: + self._open.pop(room, None) + + def answers(self, room: str, person: str, *, at: float) -> bool: + """True once, for the asked person's first message within the window.""" + asked = self._open.get(room) + if not asked or asked[0] != person: + return False + self._open.pop(room) + return at - asked[1] <= self._window diff --git a/stacklets/agent/runtime/sitecustomize.py b/stacklets/agent/runtime/sitecustomize.py index e35c3862..0ad4d1b3 100644 --- a/stacklets/agent/runtime/sitecustomize.py +++ b/stacklets/agent/runtime/sitecustomize.py @@ -41,7 +41,8 @@ 7. thread_trigger (thread_trigger.py) — a message inside a thread the agent is part of counts as a mention too. A thread is a conversation; nobody repeats the name on every line of one. Scoped to threads the agent participates in, - because the archivist and mail bot thread in the same rooms. + because the archivist and mail bot thread in the same rooms. A plain reply + to one of the agent's own messages counts too. 8. join_greeting (join_greeting.py) — on being invited, take one turn and introduce the room's topic instead of joining in silence. @@ -51,6 +52,10 @@ transcript addresses it by name, like typed text. The answer starts with what was heard, quoted. +10. open_question (open_question.py) — while the agent's last message in a room + is a question, the asked person's next message there reaches it without + its name. + WHY SHIMS AND NOT A FORK nanobot 0.3 has a seam for per-turn context (runtime-context providers) and keeps a session per Matrix thread, which retired the two shims that needed @@ -93,6 +98,7 @@ `_on_media_message` passes the transcript as `[transcription: ]`; `MatrixChannel.send(self, msg: OutboundMessage)` (async, a dataclass whose `metadata["event_id"]` is the message being answered) + open_question: the voice_trigger symbols, plus `room.room_id` and `event.sender` in the gate join_greeting: `nanobot.channels.matrix.runtime.MatrixChannel._on_room_invite(self, room, event)` `MatrixChannel._handle_message(sender_id, chat_id, content, metadata, is_dm)` @@ -233,7 +239,7 @@ def _is_bot_mentioned(self, event): _log.exception("name-trigger shim could not attach (nanobot internals changed?)") -# ── thread_trigger: a reply inside the agent's own thread is addressed to it ── +# ── thread_trigger: a message in the agent's thread, or a reply to it, is for it ── # The threaded half of the same gate, in two parts because nanobot's gate is # synchronous and the question is not: `_on_message` (async) settles whether the # thread is ours and remembers it, `_is_bot_mentioned` (sync) reads that answer. @@ -242,21 +248,29 @@ def _is_bot_mentioned(self, event): # name-trigger that failed to attach costs only itself. try: import nanobot.channels.matrix.runtime as _matrix_thread + from thread_trigger import AgentMessages as _AgentMessages from thread_trigger import AgentThreads as _AgentThreads + from thread_trigger import replied_to as _replied_to from thread_trigger import thread_root as _thread_root _agent_threads = _AgentThreads() + _agent_messages = _AgentMessages() def _learn_threads(orig): - """Settle the thread question before nanobot's gate asks it.""" + """Settle the thread and reply questions before nanobot's gate asks them.""" async def _wrapped(self, room, event): + # Both swallow their own failures; a homeserver hiccup must not + # stop the message being routed by the other rules. root = _thread_root(event) if root and not _agent_threads.includes(root): - # Swallows its own failures; a homeserver hiccup must not - # stop the message being routed by the other rules. await _agent_threads.observe( self.client, room.room_id, root, self.config.user_id, ) + target = _replied_to(event) + if target and not _agent_messages.includes(target): + await _agent_messages.observe( + self.client, room.room_id, target, self.config.user_id, + ) return await orig(self, room, event) return _wrapped @@ -266,8 +280,9 @@ def _is_bot_mentioned_or_our_thread(self, event): if _orig_mentioned_pre_thread(self, event): return True try: - root = _thread_root(event) - return bool(root and _agent_threads.includes(root)) + root, target = _thread_root(event), _replied_to(event) + return bool(root and _agent_threads.includes(root) + or target and _agent_messages.includes(target)) except Exception: _log.exception("thread trigger failed; addressing by name still works") return False @@ -343,6 +358,51 @@ async def _send(self, msg): _log.exception("voice-trigger shim could not attach (nanobot internals changed?)") +# ── open_question: the answer to the agent's own question needs no name ───── +# While the agent's last message in a room asks someone something, that +# person's next message there reaches the agent without its name. Who the +# agent is talking to comes from the message it handles, the question from +# the message it sends. See open_question.py. +try: + import time as _time_question + + import nanobot.channels.matrix.runtime as _matrix_question + from open_question import OpenQuestions as _OpenQuestions + from open_question import asks as _asks + + _open_questions = _OpenQuestions() + _talking_to: dict[str, str] = {} + _orig_should_process_q = _matrix_question.MatrixChannel._should_process_message + _orig_handle_q = _matrix_question.MatrixChannel._handle_message + _orig_send_q = _matrix_question.MatrixChannel.send + + def _should_process_or_answer(self, room, event): + if _orig_should_process_q(self, room, event): + return True + return _open_questions.answers(getattr(room, "room_id", ""), event.sender, + at=_time_question.monotonic()) + + async def _handle_and_remember(self, sender_id, chat_id, content, *args, **kwargs): + _talking_to[chat_id] = sender_id + return await _orig_handle_q(self, sender_id, chat_id, content, *args, **kwargs) + + async def _send_and_remember(self, msg): + if msg.content: + if _asks(msg.content): + _open_questions.asked(msg.chat_id, _talking_to.get(msg.chat_id, ""), + at=_time_question.monotonic()) + else: + _open_questions.closed(msg.chat_id) + return await _orig_send_q(self, msg) + + _matrix_question.MatrixChannel._should_process_message = _should_process_or_answer + _matrix_question.MatrixChannel._handle_message = _handle_and_remember + _matrix_question.MatrixChannel.send = _send_and_remember + _log.info("open-question shim active") +except Exception: + _log.exception("open-question 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 diff --git a/stacklets/agent/runtime/thread_trigger.py b/stacklets/agent/runtime/thread_trigger.py index 70c8dc48..4e04c7c1 100644 --- a/stacklets/agent/runtime/thread_trigger.py +++ b/stacklets/agent/runtime/thread_trigger.py @@ -14,7 +14,9 @@ put those on the list too <- addressed, no name So this module supplies the other half of the gate: a message in a -thread the agent is part of counts as being spoken to. +thread the agent is part of counts as being spoken to. So does a plain +reply to one of the agent's own messages (`replied_to`, `AgentMessages`): +whoever taps Reply on the agent's question is answering the agent. WHICH THREADS @@ -92,6 +94,54 @@ def thread_root(event) -> str | None: return root if isinstance(root, str) and root else None +def replied_to(event) -> str | None: + """The id of the message `event` replies to, or None if it is no plain reply. + + A message inside a thread carries an `m.in_reply_to` too, as the + fallback for clients without threads; that one is the thread rule's + business (`thread_root`), not a reply. + """ + source = getattr(event, "source", None) + content = source.get("content") if isinstance(source, dict) else None + relation = content.get("m.relates_to") if isinstance(content, dict) else None + if not isinstance(relation, dict) or relation.get("rel_type") == "m.thread": + return None + reply = relation.get("m.in_reply_to") + target = reply.get("event_id") if isinstance(reply, dict) else None + return target if isinstance(target, str) and target else None + + +class AgentMessages: + """The agent's own messages that someone replied to. + + A reply names the message it answers, which is as clear an address as + the agent's name, and it does not depend on the family's app marking + the agent as mentioned. A reply to a person's message, or to another + bot's, is theirs. Same shape as `AgentThreads`: `observe` asks the + homeserver once and remembers a yes, `includes` is the gate's lookup. + """ + + def __init__(self) -> None: + self._ours: set[str] = set() + + def includes(self, event_id: str) -> bool: + return event_id in self._ours + + async def observe(self, client, room_id: str, event_id: str, agent_user_id: str) -> bool: + """Settle whether `event_id` is the agent's message; any failure reads as no.""" + if event_id in self._ours: + return True + try: + resp = await client.room_get_event(room_id, event_id) + except Exception: + _log.debug("replied-to event fetch failed for %s", event_id, exc_info=True) + return False + if getattr(getattr(resp, "event", None), "sender", None) != agent_user_id: + return False + self._ours.add(event_id) + return True + + class AgentThreads: """The threads this agent is a participant in. diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 898738e3..d8bfd949 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -46,7 +46,8 @@ LISTS: no list fits (a new store) -> op=create list= title about topic sections, then add "belongs here|rename|new order|new sections" -> op=set list= with only what changes "delete|drop list X|don't need X any more" -> op=delete list=X; never empty it item by item - delete answer "still has N open items" -> ask whether those go too; yes -> op=delete confirmed=true + delete answer "Not deleted" -> ask whether the open items go too; their yes -> op=delete confirmed=true + never exec rm|mv on vault files; the list store is the only way to change a list add to a list with sections -> section = one of the list's own "for " | "by " -> add with people=[name] | due=YYYY-MM-DD; never guess either two items fit what was said -> ask which one, never pick diff --git a/tests/unit/stacklets/conftest.py b/tests/unit/stacklets/conftest.py index 35c32419..ec6cee1e 100644 --- a/tests/unit/stacklets/conftest.py +++ b/tests/unit/stacklets/conftest.py @@ -58,7 +58,12 @@ class Tool: pass def tool_parameters(schema): - return lambda cls: cls + # nanobot attaches the schema to the class it decorates; a tool + # without it is abstract and nanobot cannot register it. + def attach(cls): + cls.parameters_schema = schema + return cls + return attach # nanobot's own signature: `required`, `description` and # `additional_properties` are the schema's, everything else is a @@ -207,6 +212,8 @@ def mod(name, **attrs): BooleanSchema=_Schema, tool_parameters_schema=tool_parameters_schema) mod("nanobot.agent.tools.loader", ToolLoader=ToolLoader) + # nanobot 0.3: the request a tool runs in. `turn_id` is unique per turn. + mod("nanobot.agent.tools.context", current_request_context=lambda: None) mod("nanobot.agent.tools.registry", ToolRegistry=ToolRegistry) mod("nanobot.agent.tools.filesystem", WriteFileTool=WriteFileTool, EditFileTool=EditFileTool) diff --git a/tests/unit/stacklets/test_agent_open_question.py b/tests/unit/stacklets/test_agent_open_question.py new file mode 100644 index 00000000..1b1b46a5 --- /dev/null +++ b/tests/unit/stacklets/test_agent_open_question.py @@ -0,0 +1,61 @@ +"""When does a message answer the agent's own question? + +The agent asks before acting on anything unclear, and in a room with +several people the answer rarely repeats the agent's name ("Ja, die +können weg."). These pin who may answer without the name, and for how +long: the person asked, once, while the question is fresh. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "stacklets" / "agent" / "runtime")) + +from open_question import WINDOW_SECONDS, OpenQuestions, asks # noqa: E402 + +KITCHEN, HOMER, MARGE = "!kitchen", "@homer:simpson", "@marge:simpson" + + +def _asked_homer() -> OpenQuestions: + questions = OpenQuestions() + questions.asked(KITCHEN, HOMER, at=100.0) + return questions + + +def test_the_person_asked_answers_without_the_name(): + assert _asked_homer().answers(KITCHEN, HOMER, at=130.0) + + +def test_only_their_first_message_counts(): + questions = _asked_homer() + questions.answers(KITCHEN, HOMER, at=130.0) + assert not questions.answers(KITCHEN, HOMER, at=140.0) + + +def test_someone_else_still_needs_the_name_and_leaves_the_question_open(): + questions = _asked_homer() + assert not questions.answers(KITCHEN, MARGE, at=110.0) + assert questions.answers(KITCHEN, HOMER, at=120.0) + + +def test_an_answer_long_after_the_question_is_not_one(): + assert not _asked_homer().answers(KITCHEN, HOMER, at=100.0 + WINDOW_SECONDS + 1) + + +def test_another_room_is_another_conversation(): + assert not _asked_homer().answers("!garage", HOMER, at=110.0) + + +def test_whatever_the_agent_says_next_closes_the_question(): + questions = _asked_homer() + questions.closed(KITCHEN) + assert not questions.answers(KITCHEN, HOMER, at=110.0) + + +def test_a_question_is_one_that_ends_asking(): + assert asks("Die Partyliste hat noch 2 offene Punkte. Sollen die auch weg?") + assert asks("Which one, the oat milk or the whole milk? 🥛") + assert not asks("Is it the oat milk? I put it on the list.") + assert not asks("Milch steht jetzt auf der Einkaufsliste.") diff --git a/tests/unit/stacklets/test_agent_runtime_shims.py b/tests/unit/stacklets/test_agent_runtime_shims.py index 4e7229cd..1a44018c 100644 --- a/tests/unit/stacklets/test_agent_runtime_shims.py +++ b/tests/unit/stacklets/test_agent_runtime_shims.py @@ -26,7 +26,7 @@ SHIMMED_MODULES = ("sitecustomize", "brief", "state_log", "memory_tool", "person_tool", "history_tool", "name_trigger", "thread_trigger", "join_greeting", "vault_write", - "list_tool", "tool_trim", "voice_trigger") + "list_tool", "tool_trim", "voice_trigger", "open_question") # The stub nanobot itself lives in conftest as `nanobot_stub`, shared with @@ -216,6 +216,35 @@ def test_a_reply_in_the_agents_own_thread_is_processed(nanobot): assert channel.processed == [event] +def test_a_reply_to_the_agents_message_is_processed(nanobot): + """Reply to Stacky's question, no name, no pill: the reply is the address.""" + import asyncio + import types as _types + + channel = nanobot()["nanobot.channels.matrix.runtime"].MatrixChannel() + room = _room_with_thread(channel, root_sender=channel.config.user_id) + event = _types.SimpleNamespace(sender="@marge:home.local", source={"content": { + "body": "the oat one", "m.relates_to": {"m.in_reply_to": {"event_id": "$which"}}}}) + + asyncio.run(channel._on_message(room, event)) + + assert channel.processed == [event] + + +def test_a_reply_to_someone_else_is_left_alone(nanobot): + import asyncio + import types as _types + + channel = nanobot()["nanobot.channels.matrix.runtime"].MatrixChannel() + room = _room_with_thread(channel, root_sender="@homer:home.local") + event = _types.SimpleNamespace(sender="@marge:home.local", source={"content": { + "body": "the oat one", "m.relates_to": {"m.in_reply_to": {"event_id": "$homers"}}}}) + + asyncio.run(channel._on_message(room, event)) + + assert channel.processed == [] + + def test_another_bots_thread_is_left_alone(nanobot): """The shim widens the gate; it must not open it. @@ -369,6 +398,39 @@ class _Text: assert not channel._should_process_message(None, _Text()) +# ── the answer to the agent's own question ─────────────────────────────── + +def test_the_answer_to_the_agents_question_needs_no_name(nanobot, monkeypatch): + """"Sollen die auch weg?" "Ja, die können weg." reaches the agent; once.""" + import asyncio + import dataclasses + import types as _types + + @dataclasses.dataclass + class _Outbound: + chat_id: str + content: str + metadata: dict + + class _Text: + sender = "@homer:simpson" + body = "Ja, die können weg." + pill_mention = False + source = {"content": {"msgtype": "m.text", "body": "Ja, die können weg."}} + + monkeypatch.setenv("AGENT_NAME", "Stacky") + channel = nanobot()["nanobot.channels.matrix.runtime"].MatrixChannel() + kitchen = _types.SimpleNamespace(room_id="!kitchen") + asyncio.run(channel._handle_message( + sender_id="@homer:simpson", chat_id="!kitchen", + content="Stacky, lösch die Partyliste", metadata={"event_id": "$ask"})) + asyncio.run(channel.send(_Outbound( + "!kitchen", "Die Partyliste hat noch 2 offene Punkte. Sollen die auch weg?", {}))) + + assert channel._should_process_message(kitchen, _Text()) + assert not channel._should_process_message(kitchen, _Text()), "only the first answer" + + # ── failure is contained, and visible ──────────────────────────────────── def test_a_moved_symbol_does_not_take_the_others_down(nanobot): diff --git a/tests/unit/stacklets/test_agent_thread_trigger.py b/tests/unit/stacklets/test_agent_thread_trigger.py index 7c76116a..2dca1521 100644 --- a/tests/unit/stacklets/test_agent_thread_trigger.py +++ b/tests/unit/stacklets/test_agent_thread_trigger.py @@ -31,7 +31,7 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) -from thread_trigger import AgentThreads, thread_root # noqa: E402 +from thread_trigger import AgentMessages, AgentThreads, replied_to, thread_root # noqa: E402 AGENT = "@stacky-bot:home.local" ROOM = "!family:home.local" @@ -253,3 +253,68 @@ async def test_a_total_failure_is_simply_not_addressed(self): async def test_an_unknown_root_is_not_addressed(self): # The homeserver answers, it just has nothing for that id. assert not await AgentThreads().observe(_FakeClient(), ROOM, "$gone", AGENT) + + +# ── replies ────────────────────────────────────────────────────────────── + +def _reply_to(target: str): + """A message a family member sent with the client's Reply, outside a thread.""" + return SimpleNamespace(sender="@marge:home.local", source={"content": { + "body": "> <@stacky-bot:home.local> Which milk?\n\nthe oat one", + "m.relates_to": {"m.in_reply_to": {"event_id": target}}, + }}) + + +class TestRepliesToTheAgent: + """A reply names the message it answers, which is as clear an address as a name. + + Whether the client also marks the agent as mentioned in a reply + differs between apps, so the reply itself is the signal: a reply to + one of the agent's messages is for the agent; a reply to a person or + to another bot is not. + """ + + def test_a_reply_names_the_message_it_answers(self): + assert replied_to(_reply_to("$question:home.local")) == "$question:home.local" + + def test_a_threaded_message_is_the_thread_rules_not_a_reply(self): + assert replied_to(_in_thread("$root:home.local")) is None + + def test_a_plain_message_replies_to_nothing(self): + assert replied_to(SimpleNamespace(source={"content": {"body": "hi"}})) is None + + @pytest.mark.asyncio + async def test_a_reply_to_the_agents_message_is_for_the_agent(self): + client = _FakeClient() + client.events["$question:home.local"] = _said_by(AGENT) + messages = AgentMessages() + + assert await messages.observe(client, ROOM, "$question:home.local", AGENT) + assert messages.includes("$question:home.local") + + @pytest.mark.asyncio + async def test_a_reply_to_a_person_or_another_bot_is_not(self): + client = _FakeClient() + client.events["$marge:home.local"] = _said_by("@marge:home.local") + client.events["$filed:home.local"] = _said_by("@archivist-bot:home.local") + messages = AgentMessages() + + assert not await messages.observe(client, ROOM, "$marge:home.local", AGENT) + assert not await messages.observe(client, ROOM, "$filed:home.local", AGENT) + + @pytest.mark.asyncio + async def test_a_known_message_is_never_looked_up_twice(self): + client = _FakeClient() + client.events["$question:home.local"] = _said_by(AGENT) + messages = AgentMessages() + await messages.observe(client, ROOM, "$question:home.local", AGENT) + await messages.observe(client, ROOM, "$question:home.local", AGENT) + + assert client.event_lookups == ["$question:home.local"] + + @pytest.mark.asyncio + async def test_a_homeserver_failure_reads_as_not_the_agents(self): + client = _FakeClient() + client.get_event_raises = RuntimeError("homeserver down") + + assert not await AgentMessages().observe(client, ROOM, "$question:home.local", AGENT) diff --git a/tests/unit/stacklets/test_agent_vault_tools.py b/tests/unit/stacklets/test_agent_vault_tools.py index 625a00c4..0ccf86f7 100644 --- a/tests/unit/stacklets/test_agent_vault_tools.py +++ b/tests/unit/stacklets/test_agent_vault_tools.py @@ -25,6 +25,7 @@ from __future__ import annotations import asyncio +import types import importlib import importlib.machinery import importlib.util @@ -122,6 +123,20 @@ async def _fake_exec(*args, **_kwargs): return captured +# ── gate 0: every tool carries its parameters ──────────────────────── + +@pytest.mark.parametrize("tool_name", ["memory_search", "memory_person", "memory_history", + "list_edit"]) +def test_every_tool_carries_its_own_parameters(vault_tools, tool_name): + """A tool nanobot cannot instantiate is missing from the model's list. + + On the rig a helper class landed between `@tool_parameters(...)` and + `ListEditTool`: the schema went to the helper, `list_edit` was + abstract, and the agent answered that it had no list tool. + """ + assert "parameters_schema" in vars(vault_tools[tool_name]) + + # ── gate 1: the core API allowlist ─────────────────────────────────── @pytest.mark.parametrize("tool_name,kwargs", [ @@ -236,24 +251,77 @@ def test_search_sends_no_backend_flag(vault_tools): assert "--backend" not in argv -def test_a_list_with_open_items_is_deleted_only_once_the_person_said_yes(vault_tools): - """The store refuses to delete a list with open items unless told `--yes`. +REFUSED = (b" \xe2\x9c\x97 Partyliste still has 2 open items: Luftballons; Girlanden. " + b"Delete it with them: add --yes\n") + + +def deletes(tool_cls, turns, monkeypatch) -> list[bool]: + """Run delete calls turn by turn; for each, whether `--yes` was sent. - The model decides nothing here: `--yes` is sent only when it says the - person confirmed, so a misheard "delete" meets the refusal that names - the open items, and the person is asked. + `turns` holds (turn id, confirmed, what the store answers). The store's + answer is fed back to the tool as the CLI's output, as on the rig. """ - asked = argv_of(vault_tools["list_edit"], list="baumarkt", op="delete") - confirmed = argv_of(vault_tools["list_edit"], list="baumarkt", op="delete", - confirmed=True) + import sys as _sys - unclear = argv_of(vault_tools["list_edit"], list="baumarkt", op="delete", - confirmed="maybe") + context = _sys.modules["nanobot.agent.tools.context"] + sent: list[bool] = [] + tool = tool_cls() + for turn, confirmed, answer in turns: + monkeypatch.setattr(context, "current_request_context", + lambda turn=turn: types.SimpleNamespace(turn_id=turn)) - assert "--yes" not in asked - assert "--yes" not in unclear, "only a real yes deletes open items" - assert confirmed[:5] == ["stack", "memory", "list", "delete", "baumarkt"] - assert "--yes" in confirmed + class _Proc: + returncode = 1 if answer == REFUSED else 0 + + async def communicate(self, answer=answer): + return answer, b"" + + async def _fake_exec(*args, **_kwargs): + sent.append("--yes" in args) + return _Proc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + asyncio.run(tool.execute(op="delete", list="partyliste", confirmed=confirmed)) + return sent + + +def test_a_list_with_open_items_is_deleted_only_after_the_person_said_yes(vault_tools, monkeypatch): + """The model cannot confirm for the person. + + On the rig (2026-10-12) the store refused a delete because of open + items and the model sent `confirmed` itself in the same turn, so the + list went without anyone being asked. A yes counts only in a later + message than the refusal. + """ + sent = deletes(vault_tools["list_edit"], [ + ("turn-1", None, REFUSED), # "delete the party list" + ("turn-1", True, REFUSED), # the model confirming on its own + ("turn-2", True, b"deleted the list Partyliste (by homer)"), # "Ja, die können weg." + ], monkeypatch) + assert sent == [False, False, True] + + +def test_a_refused_delete_tells_the_model_to_ask_naming_what_is_open(vault_tools, monkeypatch): + import sys as _sys + monkeypatch.setattr(_sys.modules["nanobot.agent.tools.context"], "current_request_context", + lambda: types.SimpleNamespace(turn_id="turn-1")) + answer = result_of(vault_tools["list_edit"], returncode=1, stdout=REFUSED, + op="delete", list="partyliste") + assert answer.startswith("Not deleted: Partyliste still has 2 open items: Luftballons; " + "Girlanden. Ask the person whether these go too") + assert "--yes" not in answer + + +def test_a_yes_without_a_refusal_before_it_deletes_nothing_open(vault_tools, monkeypatch): + sent = deletes(vault_tools["list_edit"], [("turn-1", True, REFUSED)], monkeypatch) + assert sent == [False] + + +def test_only_a_real_yes_counts(vault_tools, monkeypatch): + """nanobot casts "true"/"false" but passes any other string through.""" + sent = deletes(vault_tools["list_edit"], [ + ("turn-1", None, REFUSED), ("turn-2", "maybe", REFUSED)], monkeypatch) + assert sent == [False, False] def result_of(tool_cls, *, returncode: int, stdout: bytes = b"",