From b0083a6941c532bef70f7b1d3c1adb47956d24c8 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 20:22:12 +0200 Subject: [PATCH 1/3] feat(agent): answer a reply to the agent's message without its name A reply to one of Stacky's messages reached Stacky only if the family's app marked Stacky as mentioned in it, which depends on the app. A plain reply was otherwise treated as a quote and needed the name. A reply names the message it answers, so a reply to one of Stacky's own messages now reaches Stacky without the name, whatever the app. A reply to a person's message or another bot's is left alone. Whose message it was is asked from the homeserver once and remembered. --- stacklets/agent/runtime/open_question.py | 60 +++++++++++++++++ stacklets/agent/runtime/sitecustomize.py | 24 +++++-- stacklets/agent/runtime/thread_trigger.py | 52 +++++++++++++- .../stacklets/test_agent_open_question.py | 61 +++++++++++++++++ .../stacklets/test_agent_runtime_shims.py | 29 ++++++++ .../stacklets/test_agent_thread_trigger.py | 67 ++++++++++++++++++- 6 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 stacklets/agent/runtime/open_question.py create mode 100644 tests/unit/stacklets/test_agent_open_question.py 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..b32cd31b 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. @@ -233,7 +234,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 +243,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 +275,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 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/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..e3ff6377 100644 --- a/tests/unit/stacklets/test_agent_runtime_shims.py +++ b/tests/unit/stacklets/test_agent_runtime_shims.py @@ -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. 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) From 5e59feb8cb8a8b6c6bf0f9019182b09824828ca1 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 20:22:19 +0200 Subject: [PATCH 2/3] feat(agent): take the answer to the agent's question without its name When Stacky asked something in a room ("Die Partyliste hat noch 2 offene Punkte. Sollen die auch weg?"), the answer got no reply unless it started with Stacky's name again, so the conversation Stacky started ended in silence. While Stacky's last message in a room is a question, the next message from the person it was talking to reaches Stacky without the name, if it comes within five minutes. Only that one message; anyone else, or a later message, needs the name as before. Anything else Stacky says in the room closes the question. Closes: #200 --- stacklets/agent/runtime/sitecustomize.py | 50 +++++++++++++++++++ .../stacklets/test_agent_runtime_shims.py | 35 ++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/stacklets/agent/runtime/sitecustomize.py b/stacklets/agent/runtime/sitecustomize.py index b32cd31b..0ad4d1b3 100644 --- a/stacklets/agent/runtime/sitecustomize.py +++ b/stacklets/agent/runtime/sitecustomize.py @@ -52,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 @@ -94,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)` @@ -353,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/tests/unit/stacklets/test_agent_runtime_shims.py b/tests/unit/stacklets/test_agent_runtime_shims.py index e3ff6377..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 @@ -398,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): From 74ec61446f3fd31aabea5c1c0318dd726d1c08b6 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 20:30:14 +0200 Subject: [PATCH 3/3] fix(agent): delete a list with open items only after the person says yes Asked to delete a list that still had open items, Stacky deleted it without asking: the store refused because of the open items, and the model sent the confirmation itself in the same turn. A confirmation now counts only in a later turn than the refusal, which is a message from the person after Stacky told them what is still open. Any other confirmation is dropped, the store refuses again, and Stacky has to ask. Fixes: 1e0d216 ("feat(agent): delete a list when the family asks") --- stacklets/agent/runtime/list_tool.py | 71 +++++++++++++- .../workspace/skills/family-memory/SKILL.md | 3 +- tests/unit/stacklets/conftest.py | 9 +- .../unit/stacklets/test_agent_vault_tools.py | 96 ++++++++++++++++--- 4 files changed, 159 insertions(+), 20 deletions(-) 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/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_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"",