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
71 changes: 67 additions & 4 deletions stacklets/agent/runtime/list_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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]
Expand All @@ -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 <op> [<list>] <items>... [flags]` for an item operation."""
args = ["stack", "memory", "list", op]
Expand Down
60 changes: 60 additions & 0 deletions stacklets/agent/runtime/open_question.py
Original file line number Diff line number Diff line change
@@ -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
74 changes: 67 additions & 7 deletions stacklets/agent/runtime/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -93,6 +98,7 @@
`_on_media_message` passes the transcript as `[transcription: <text>]`;
`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)`

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion stacklets/agent/runtime/thread_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion stacklets/agent/workspace/skills/family-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ LISTS:
no list fits (a new store) -> op=create list=<slug> title about topic sections, then add
"belongs here|rename|new order|new sections" -> op=set list=<slug> 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 <name>" | "by <day>" -> add with people=[name] | due=YYYY-MM-DD; never guess either
two items fit what was said -> ask which one, never pick
Expand Down
9 changes: 8 additions & 1 deletion tests/unit/stacklets/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading