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
2 changes: 1 addition & 1 deletion stacklets/agent/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
}
},
"tools": {
"exec": {"enable": true},
"exec": {"enable": true, "deny_patterns": ["\\blist\\s+delete\\b.*\\s--y"]},
"web": {"enable": false},
"file": {"enable": true},
"cli_apps": {"enable": false},
Expand Down
16 changes: 3 additions & 13 deletions stacklets/agent/runtime/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from datetime import datetime
from pathlib import Path

from turn import person_of

# Strip footnote/citation markers the curator leaves in generated pages, e.g.
# "Safety Inspector [8]" -> "Safety Inspector". They are noise in a briefing.
_CITE = re.compile(r"\s*\[\d+(?:,\s*\d+)*\]")
Expand Down Expand Up @@ -165,12 +167,6 @@ def when_sent(now: datetime) -> str:
return f"Sent {now:%A, %Y-%m-%d %H:%M} ({now.tzname()})."


# Who the agent is currently talking to, refreshed each turn by
# `brief_lines`. A tool call has no access to the Matrix message, so this
# is how a vault write learns whose name goes on the commit.
speaking_with = ""


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 All @@ -181,14 +177,8 @@ def brief_lines(msg, workspace) -> list[str]:
# WHO — the speaker. sender_id is an mxid like "@homer:simpson"; the localpart
# is the vault person slug. This is the reliable, high-value part: it fixes
# multi-user rooms where "me/my" must resolve to whoever actually spoke.
localpart = str(getattr(msg, "sender_id", "")).split(":")[0].lstrip("@")
localpart = person_of(getattr(msg, "sender_id", ""))
if localpart:
# Remember who is speaking so a vault write made later in this turn is
# committed as them. The briefing is rebuilt every turn and always
# before any tool runs, so this is current by construction; a tool has
# no other route to the sender. See vault_write.py.
global speaking_with
speaking_with = localpart
essence = _lead(vault / localpart / "about.md")
head = f"You are speaking with {localpart} (@{localpart})."
lines.append(f"{head} {essence}" if essence else head)
Expand Down
54 changes: 24 additions & 30 deletions stacklets/agent/runtime/list_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,40 @@
StringSchema,
tool_parameters_schema,
)
from turn import Turn, current as current_turn


class AwaitingYes:
"""Lists whose delete the store refused because of open items, by turn.
"""Lists whose delete the store refused because of open items, and who was told.

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.
the same turn. So a yes counts only from the person who asked, in the
same conversation, in a later turn than the refusal: their message
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
awaiting.refused("partyliste", Turn("homer", "!kitchen", "t1"))
awaiting.allows("partyliste", Turn("homer", "!kitchen", "t1")) # False: same turn
awaiting.allows("partyliste", Turn("bart", "!kitchen", "t2")) # False: not asked
awaiting.allows("partyliste", Turn("homer", "!kitchen", "t2")) # True
"""

def __init__(self) -> None:
self._refused_in: dict[str, str] = {}
self._refused_in: dict[tuple[str, str, str], str] = {}

def refused(self, slug: str, *, turn: str) -> None:
if turn:
self._refused_in[slug] = turn
def refused(self, slug: str, turn: Turn) -> None:
if turn.id:
self._refused_in[(turn.conversation, turn.person, slug)] = turn.id

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 allows(self, slug: str, turn: Turn) -> bool:
refused_in = self._refused_in.get((turn.conversation, turn.person, slug), "")
return bool(refused_in and turn.id and turn.id != refused_in)

def done(self, slug: str) -> None:
self._refused_in.pop(slug, None)
"""The list is gone, for everyone who was waiting on it."""
for key in [k for k in self._refused_in if k[2] == slug]:
del self._refused_in[key]


# What the store says when a list to delete still has open items.
Expand All @@ -58,14 +63,6 @@ def done(self, slug: str) -> None:
_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 @@ -168,20 +165,17 @@ async def execute(
sections: "list[str] | None" = None,
confirmed: bool | None = None,
) -> str:
try:
import brief
actor = getattr(brief, "speaking_with", "") or "someone"
except Exception:
actor = "someone"
now = current_turn()
actor = now.person or "someone"

if op in ("create", "set"):
args = _details_args(op, list, title, about, topic, sections)
elif op == "delete":
slug, turn = (list or "").strip(), _current_turn()
slug = (list or "").strip()
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):
if confirmed is True and _awaiting_yes.allows(slug, now):
args.append("--yes")
else:
args = _item_args(op, list, items, topic, section, people, due, to)
Expand All @@ -202,7 +196,7 @@ async def execute(
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)
_awaiting_yes.refused(slug, now)
return _ask_first(out or err)
if proc.returncode == 0:
_awaiting_yes.done(slug)
Expand Down
26 changes: 25 additions & 1 deletion stacklets/agent/runtime/open_question.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@
treated as before. Whatever else the agent says in the room closes the
question, since it is no longer the last thing asked.

The question is for whoever sent the message it answers, which is not
always the last person to write in the room.

questions = OpenQuestions()
questions.asked("!kitchen", "@homer:simpson", at=100.0)
questions.heard("$ask", "@homer:simpson")
questions.replied("!kitchen", "$ask", "Sollen die auch weg?", at=100.0)
questions.answers("!kitchen", "@marge:simpson", at=110.0) # False
questions.answers("!kitchen", "@homer:simpson", at=120.0) # True, once
"""
Expand All @@ -28,6 +32,10 @@
# is not taken for one.
WINDOW_SECONDS = 300

# Senders of messages the agent handled, kept for their answers. Far more
# than are ever in flight at once in a household.
_REMEMBERED = 100

# The end of a message, past trailing quotes, brackets, emoji and spaces.
_TRAILING = re.compile(r"[\s\"'”“»«)\]*_~\U0001F300-\U0001FAFF☀-➿]+$")

Expand All @@ -43,6 +51,22 @@ class OpenQuestions:
def __init__(self, window: float = WINDOW_SECONDS) -> None:
self._window = window
self._open: dict[str, tuple[str, float]] = {}
self._senders: dict[str, str] = {}

def heard(self, event_id: str, person: str) -> None:
"""The agent is handling `event_id`, sent by `person`."""
if event_id and person:
self._senders[event_id] = person
while len(self._senders) > _REMEMBERED:
del self._senders[next(iter(self._senders))]

def replied(self, room: str, event_id: str, text: str, *, at: float) -> None:
"""The agent said `text` in answer to `event_id`: a question opens, anything else closes."""
person = self._senders.get(event_id, "")
if asks(text) and person:
self.asked(room, person, at=at)
else:
self.closed(room)

def asked(self, room: str, person: str, *, at: float) -> None:
if room and person:
Expand Down
Loading
Loading