diff --git a/docs/adr/adr-012-nanobot-fork.md b/docs/adr/adr-012-nanobot-fork.md index 641bd846..fa85085c 100644 --- a/docs/adr/adr-012-nanobot-fork.md +++ b/docs/adr/adr-012-nanobot-fork.md @@ -212,3 +212,25 @@ Two shims removed; history size is left to nanobot's own settings. `config.json` sets the primary preset's `context_window_tokens` to 32768, so nanobot's token consolidation and history snip act at a size that keeps prefill short. + +## Update 2026-10-12 + +The agent moved to nanobot 0.3.5. Two of the seams this ADR wanted a fork for +now exist upstream, and the fork is not needed for them: + +| Shim | Change | Reason | +|---|---|---| +| `brief` | a runtime-context provider (`AgentLoop.register_runtime_context_provider`) instead of a patched `runtime_lines` | upstream seam; nanobot keeps the block with the message in the history, which keeps the prompt prefix cached across turns | +| `thread_session` | removed | the Matrix channel keys a session per thread itself | +| `compact_tools` | removed | microcompact is gone; the runner compacts the history under context pressure | + +What still patches internals: the Matrix addressing gate (`name_trigger`, +`thread_trigger`, `join_greeting`, on `channels.matrix.runtime.MatrixChannel`), +the three write tools (`vault_write`) and tool registration (`tool_trim`). The +fork question is now about those five and stays open: a group-policy or +addressing hook upstream would retire the first three. + +Measured on the rig, same four list turns in a long room session: the first +model call of a turn reused 8192 of about 12,300 prompt tokens on 0.2.2 +(the previous user message was replayed in a different form than it was +sent), and close to the whole previous prompt on 0.3.5. diff --git a/stacklets/agent/Dockerfile b/stacklets/agent/Dockerfile index 00d271ee..2d1a38b7 100644 --- a/stacklets/agent/Dockerfile +++ b/stacklets/agent/Dockerfile @@ -1,6 +1,7 @@ # Dockerfile — the agent runtime: the nanobot harness. # -# nanobot from PyPI with the Matrix extra (matrix-nio[e2e], which needs libolm). +# nanobot from PyPI plus its Matrix channel's dependencies (matrix-nio[e2e], +# which needs libolm). # config.json and the personality seeds are copied to /app and installed into # the mounted ~/.nanobot by entrypoint.sh at start, so the bind-mounted volume # never shadows them and config is refreshed from stack.toml-derived env. @@ -22,7 +23,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # # Bump this deliberately, in its own change, and re-verify every symbol in # sitecustomize.py's PIN / RECHECK list against the new release. -RUN uv pip install --system --no-cache "nanobot-ai[matrix]==0.2.2" +RUN uv pip install --system --no-cache "nanobot-ai==0.3.5" + +# Since 0.3 a channel declares its own dependencies in its plugin manifest and +# `nanobot plugins enable` installs them at runtime. Installed here instead, +# from that same list, so the image is complete before it starts and the list +# moves with the pinned release. +RUN python -c "from nanobot.channels.matrix.manifest import PLUGIN; \ +print('\n'.join(PLUGIN.dependencies))" > /tmp/matrix-requirements.txt \ + && uv pip install --system --no-cache -r /tmp/matrix-requirements.txt \ + && rm /tmp/matrix-requirements.txt # Non-root, matching nanobot's own image (uid 1000, HOME=/home/nanobot). RUN useradd -m -u 1000 -s /bin/bash nanobot diff --git a/stacklets/agent/config.json b/stacklets/agent/config.json index 66376d13..01d66208 100644 --- a/stacklets/agent/config.json +++ b/stacklets/agent/config.json @@ -16,7 +16,6 @@ "defaults": { "model_preset": "primary", "max_tool_iterations": 12, - "max_messages": 40, "disabled_skills": ["memory", "my"] } }, @@ -29,6 +28,9 @@ "image_generation": {"enable": false} }, "channels": { + "sendProgress": false, + "sendToolHints": false, + "showReasoning": false, "matrix": { "enabled": true, "homeserver": "${AGENT_MATRIX_HOMESERVER}", @@ -37,6 +39,7 @@ "e2eeEnabled": true, "allowFrom": ["*"], "groupPolicy": "mention" - } + }, + "websocket": {"enabled": false} } } diff --git a/stacklets/agent/runtime/README.md b/stacklets/agent/runtime/README.md index ec5865a4..729797e4 100644 --- a/stacklets/agent/runtime/README.md +++ b/stacklets/agent/runtime/README.md @@ -1,19 +1,17 @@ # Agent runtime shims — per-turn briefing This directory is a **contained modification of nanobot**, loaded into the agent -container. It exists because nanobot has no plugin seam for shaping per-turn -context, and we did not want to fork nanobot for a couple of hooks. Everything -here lives in the stacklet; upstream `nanobot-ai` is installed unchanged. +container. Everything here lives in the stacklet; upstream `nanobot-ai` is +installed unchanged. -The main context shim is **brief** (what the agent knows going in). History -size is left to nanobot's own settings in `config.json`: +The main context shim is **brief** (what the agent knows going in), registered +through nanobot's runtime-context provider seam. History size is left to +nanobot's own settings in `config.json`: | Mechanism | Setting | Value | Effect | |---|---|---|---| -| Replay window | `max_messages` | 40 | Older messages move to `history.jsonl` and the `# Recent History` section | -| Token budget | `model_presets.primary.context_window_tokens` | 32768 | Budget = window - 8192 output - 1024 = 23.5k; consolidation trims to 50% of it, history snip cuts at it | -| Idle autocompact | `idleCompactAfterMinutes` | 15 (default) | Idle session: summary plus the last turn | -| Microcompact | `_COMPACTABLE_TOOLS` | nanobot's read tools + vault tools (`compact_tools.py`) | Keeps the 10 newest tool results, older ones (>= 500 chars) become `[ result omitted from context]` | +| Context window | `model_presets.primary.context_window_tokens` | 32768 | nanobot compacts the history into a summary checkpoint when a request comes close to it | +| Idle compaction | `idleCompactAfterMinutes` | 15 (default) | An idle session is summarised before its next turn | A small window keeps prefill short on the local model. `AGENT_STATE_LOG=1` writes the message list of each turn to `~/.nanobot/llm-state.log`. @@ -58,28 +56,25 @@ plus pointers; it reads full pages with its file tools only if it needs depth. topic page, open todos, recent *human* git activity). Compact, read-only, never raises. - `sitecustomize.py` — auto-loaded by Python at startup (this dir is on - `PYTHONPATH`; see the stacklet `Dockerfile`). It wraps - `nanobot.agent.context.runtime_lines` to prepend our lines. nanobot appends - the runtime block **after** the stable prompt + user text, which is the - KV-cache-safe position. + `PYTHONPATH`; see the stacklet `Dockerfile`). It registers the briefing as a + runtime-context provider on every `AgentLoop`. nanobot appends the block to + the user's message and keeps it with that message in the history, so the + next turn replays exactly what the model saw: the prompt prefix stays cached. ``` Dockerfile: COPY runtime/ /app/runtime + ENV PYTHONPATH=/app/runtime -startup: python imports sitecustomize -> patches context.runtime_lines -every turn: build_messages() -> runtime_lines() -> [our briefing] + [nanobot's] - -> appended after the user text -> cache-hot prefix preserved +startup: python imports sitecustomize -> AgentLoop registers the brief provider +every turn: provider(request) -> RuntimeContextBlock -> appended to the user text, + stored with it -> the next turn's prefix is this turn's prompt ``` ## Maintenance -- **Patched symbol:** `nanobot.agent.context.runtime_lines`, - signature `(state, msg, workspace, *, skip=False) -> list[str]`. -- **On every `nanobot-ai` bump:** re-verify that symbol and signature. If they - moved, the shim logs `brief shim could not attach` at startup and the - agent runs with stock behaviour (no crash) — check the container logs. +- **Seam:** `AgentLoop.register_runtime_context_provider`, called from a wrapped + `AgentLoop.__init__`. A provider is `async (RequestContext) -> RuntimeContextBlock`. +- **On every `nanobot-ai` bump:** re-verify the PIN list in `sitecustomize.py`. + A shim that no longer attaches logs `... could not attach` at startup and the + agent runs with stock behaviour (no crash): check the container logs. - **To remove entirely:** delete this `runtime/` dir and the `PYTHONPATH` line from the `Dockerfile`. -- **When to graduate to a fork:** once we accumulate several nanobot changes - (candidates already in view: the coding-flavoured tool contract, the - single-user `USER.md` model, the `M_UNKNOWN_TOKEN` no-reauth bug), fold them - into a fork and upstream a proper context-provider API so we can un-fork. +- **Fork or not:** see `docs/adr/adr-012-nanobot-fork.md`. diff --git a/stacklets/agent/runtime/brief.py b/stacklets/agent/runtime/brief.py index a3b144fe..078cc5bd 100644 --- a/stacklets/agent/runtime/brief.py +++ b/stacklets/agent/runtime/brief.py @@ -23,6 +23,7 @@ import re import subprocess import unicodedata +from datetime import datetime from pathlib import Path # Strip footnote/citation markers the curator leaves in generated pages, e.g. @@ -153,6 +154,17 @@ def _topic_slug(msg, vault: Path) -> str: return topic_for_room_label(raw, vault) +def when_sent(now: datetime) -> str: + """When this message was sent, in the household's clock. + + nanobot 0.3 states no date or time anywhere in the prompt, so without + this line "by Friday" or "tomorrow" cannot become a date. The block it + sits in stays with its message in the history, which also dates every + earlier message for "what did we say yesterday". + """ + 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. diff --git a/stacklets/agent/runtime/compact_tools.py b/stacklets/agent/runtime/compact_tools.py deleted file mode 100644 index 5866f987..00000000 --- a/stacklets/agent/runtime/compact_tools.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Let nanobot's microcompact shorten the results of our vault tools. - -Before each model call, nanobot's `AgentRunner._microcompact` keeps the -10 most recent results of the tools in `_COMPACTABLE_TOOLS` and replaces -older ones (500 characters or more) with `[ result omitted from -context]`. The set lists nanobot's own read tools only. Our vault tools -return results of about 1k tokens each, so without this they stay in -the context in full for as long as the session replays them. - -PIN: `nanobot.agent.runner._COMPACTABLE_TOOLS` (a frozenset of tool -names, read at call time). Re-verify on a nanobot bump. -""" - -from __future__ import annotations - -# Tool names as the model sees them. list_edit is not here: its results -# are one line ("added 3") and stay under the 500-character floor. -VAULT_READ_TOOLS = frozenset({ - "memory_search", - "memory_person", - "memory_history", -}) - - -def install() -> None: - import nanobot.agent.runner as runner - - runner._COMPACTABLE_TOOLS = frozenset(runner._COMPACTABLE_TOOLS) | VAULT_READ_TOOLS diff --git a/stacklets/agent/runtime/history_tool.py b/stacklets/agent/runtime/history_tool.py index 6b2d370f..69202ace 100644 --- a/stacklets/agent/runtime/history_tool.py +++ b/stacklets/agent/runtime/history_tool.py @@ -57,7 +57,7 @@ nullable=True, ), limit=IntegerSchema( - "Optional. How many changes to return (default and at most 10).", + description="Optional. How many changes to return (default and at most 10).", nullable=True, ), ) diff --git a/stacklets/agent/runtime/memory_tool.py b/stacklets/agent/runtime/memory_tool.py index 6c08d675..e5d1b3dd 100644 --- a/stacklets/agent/runtime/memory_tool.py +++ b/stacklets/agent/runtime/memory_tool.py @@ -34,7 +34,6 @@ nullable=True, ), limit=IntegerSchema( - 5, description="Maximum number of results to return (at most 8).", minimum=1, maximum=8, diff --git a/stacklets/agent/runtime/sitecustomize.py b/stacklets/agent/runtime/sitecustomize.py index c8c2832f..fd5a6fb9 100644 --- a/stacklets/agent/runtime/sitecustomize.py +++ b/stacklets/agent/runtime/sitecustomize.py @@ -7,15 +7,15 @@ Two kinds of patch live here, each a thin monkeypatch over a pure module. -First, the context shims. History length and old tool results are left to -nanobot's own mechanisms (replay window, idle autocompact, microcompact, token -consolidation); see config.json and compact_tools.py. +First, the context. History length and old tool results are left to +nanobot's own context compaction; see config.json. -1. brief (brief.py) — prepends a per-turn family briefing (who is speaking, the - topic) to nanobot's runtime lines. Injected late (after the stable prompt and - the user's text) so it stays KV-cache-friendly: measured 4096/4746 prompt - tokens cached with late injection vs 0 injecting the same content early via - USER.md. +1. brief (brief.py) — a per-turn family briefing (who is speaking, the topic), + registered as one of nanobot's runtime-context providers. nanobot appends it + to the user's message and keeps it with that message in the history, so the + prompt prefix stays the same from one turn to the next: measured 4096/4746 + prompt tokens cached with late injection vs 0 injecting the same content + early via USER.md. 2. state_log (state_log.py) — with AGENT_STATE_LOG=1, writes the message list of each turn to ~/.nanobot/llm-state.log. Off by default. It does not change @@ -32,57 +32,56 @@ 5. history_tool (history_tool.py) — a `memory_history` tool for questions with time in them. Search ranks pages by what they say now, so "lately", "since when" and "who changed this" are unanswerable by it, silently. -6. compact_tools (compact_tools.py) — adds the vault read tools to nanobot's - microcompact set, so their old results are shortened like nanobot's own. - Third, one shim that widens when the agent is allowed to answer at all: -7. name_trigger (name_trigger.py) — a group-room message that addresses the +6. name_trigger (name_trigger.py) — a group-room message that addresses the agent by its configured name counts as a mention, not just an autocompleted pill. Families type "Stacky, what's on our list?". -8. thread_trigger (thread_trigger.py) — a message inside a thread the agent is +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. -9. join_greeting (join_greeting.py) — on being invited, take one turn and +8. join_greeting (join_greeting.py) — on being invited, take one turn and introduce the room's topic instead of joining in silence. WHY SHIMS AND NOT A FORK - nanobot has no plugin seam for per-turn context injection or state shaping. - Shims keep us on upstream `nanobot-ai` (updates included) with the change - contained in this stacklet. Tradeoff: they patch internals, so a nanobot - refactor breaks them — loudly, since we log on failure. When these grow past - a couple of hooks, fold the pure modules into a fork (each is already a clean - function) and upstream real context-provider / state-shaping APIs. + 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 + one most. What still patches internals is the Matrix addressing gate and the + write tools. See docs/adr/adr-012-nanobot-fork.md, update 2026-10-12. TO REMOVE Delete this stacklet's `runtime/` dir and drop `PYTHONPATH` from the Dockerfile. nanobot reverts to stock behaviour with no other change. PIN / RECHECK ON UPGRADE (re-verify after any `nanobot-ai` version bump) - brief: `nanobot.agent.context.runtime_lines(state, msg, workspace, *, skip=False) -> list[str]` + brief: `nanobot.agent.loop.AgentLoop.__init__` (wrapped to register a provider), + `AgentLoop.register_runtime_context_provider(provider)`, where a provider is + `async (RequestContext) -> RuntimeContextBlock | None` and RequestContext + carries `sender_id`, `metadata`, `workspace`; + `nanobot.runtime_context.{RuntimeContextBlock, wrap_runtime_context_lines}` state_log: `nanobot.agent.context.ContextBuilder.build_messages(...) -> list[dict]` memory_tool: `nanobot.agent.tools.loader.ToolLoader.discover(self) -> list[type[Tool]]` `nanobot.agent.tools.base.Tool`, `nanobot.agent.tools.base.tool_parameters` `nanobot.agent.tools.schema.{StringSchema, IntegerSchema, tool_parameters_schema}` + (IntegerSchema takes keyword arguments only) person_tool: same symbols as memory_tool history_tool: same symbols as memory_tool - compact_tools: `nanobot.agent.runner._COMPACTABLE_TOOLS` (frozenset of tool names) vault_write: `nanobot.agent.tools.filesystem.WriteFileTool.execute(self, path, content) -> str` `nanobot.agent.tools.filesystem.EditFileTool.execute(self, path, ...) -> str` `nanobot.agent.tools.apply_patch.ApplyPatchTool.execute(self, edits, ...) -> str` (all three are `async def`; a sync replacement returns a str into the loop's `await` and the tool call dies with TypeError) - name_trigger: `nanobot.channels.matrix.MatrixChannel._is_bot_mentioned(self, event) -> bool` + name_trigger: `nanobot.channels.matrix.runtime.MatrixChannel._is_bot_mentioned(self, event) -> bool` thread_trigger: `MatrixChannel._is_bot_mentioned` (same symbol, wrapped after it) `MatrixChannel._on_message(self, room, event)` (async) `MatrixChannel._on_media_message(self, room, event)` (async) `MatrixChannel.client` (nio AsyncClient), `MatrixChannel.config.user_id` — and that `groupPolicy: mention` still routes through `_is_bot_mentioned` (config.json sets that policy) - join_greeting: `nanobot.channels.matrix.MatrixChannel._on_room_invite(self, room, event)` + join_greeting: `nanobot.channels.matrix.runtime.MatrixChannel._on_room_invite(self, room, event)` `MatrixChannel._handle_message(sender_id, chat_id, content, metadata, is_dm)` `tests/unit/stacklets/test_agent_runtime_shims.py` asserts every one of these is @@ -104,25 +103,31 @@ _log = logging.getLogger("agent.runtime.shim") try: - import nanobot.agent.context as _ctx - from brief import brief_lines as _brief_lines + import datetime as _datetime - _orig_runtime_lines = _ctx.runtime_lines + import nanobot.agent.loop as _loop + from brief import brief_lines as _brief_lines + from brief import when_sent as _when_sent + from nanobot.runtime_context import RuntimeContextBlock as _Block + from nanobot.runtime_context import wrap_runtime_context_lines as _wrap - def _runtime_lines(state, msg, workspace, *, skip=False): - base = _orig_runtime_lines(state, msg, workspace, skip=skip) - if skip: # subagents skip runtime context; respect that - return base + async def _family_brief(request): + lines = [_when_sent(_datetime.datetime.now().astimezone())] try: - extra = _brief_lines(msg, workspace) + lines += _brief_lines(request, request.workspace) except Exception: # A briefing is an optimisation, never a dependency — never break a turn. _log.exception("brief failed; continuing without a briefing") - extra = [] - return list(extra) + list(base) + return _Block(source="famstack-brief", content=_wrap(lines)) + + _orig_loop_init = _loop.AgentLoop.__init__ + + def _loop_init(self, *args, **kwargs): + _orig_loop_init(self, *args, **kwargs) + self.register_runtime_context_provider(_family_brief) - _ctx.runtime_lines = _runtime_lines - _log.info("brief runtime-context shim active") + _loop.AgentLoop.__init__ = _loop_init + _log.info("brief runtime-context provider active") except Exception: # If the internal symbol moved (nanobot upgrade), fail loudly in the log but # do not stop the agent from starting. @@ -176,8 +181,6 @@ def _build_messages_logged(self, *args, **kwargs): ("vault_write", "write_file on a vault page -> stack memory write"), ("list_tool", "list_edit item tool -> stack memory list"), ("tool_trim", "unused-tool trim (AGENT_TOOL_TRIM=0 to disable)"), - ("compact_tools", "vault tools in nanobot's microcompact set"), - ("thread_session", "thread-scoped sessions (AGENT_THREAD_SESSIONS=0 to disable)"), ): try: importlib.import_module(_module_name).install() @@ -194,7 +197,7 @@ def _build_messages_logged(self, *args, **kwargs): try: import os as _os - import nanobot.channels.matrix as _matrix + import nanobot.channels.matrix.runtime as _matrix from name_trigger import addressed_by_name as _addressed_by_name _orig_is_bot_mentioned = _matrix.MatrixChannel._is_bot_mentioned @@ -226,7 +229,7 @@ def _is_bot_mentioned(self, event): # matcher keep working and this only gets a say when both said no — and so a # name-trigger that failed to attach costs only itself. try: - import nanobot.channels.matrix as _matrix_thread + import nanobot.channels.matrix.runtime as _matrix_thread from thread_trigger import AgentThreads as _AgentThreads from thread_trigger import thread_root as _thread_root @@ -278,7 +281,7 @@ def _is_bot_mentioned_or_our_thread(self, event): import os.path as _ospath from pathlib import Path as _Path - import nanobot.channels.matrix as _matrix_join + import nanobot.channels.matrix.runtime as _matrix_join from brief import topic_for_room_label as _topic_for_room_label from join_greeting import greeting_prompt as _greeting_prompt diff --git a/stacklets/agent/runtime/state_log.py b/stacklets/agent/runtime/state_log.py index 2944cd58..de3ed238 100644 --- a/stacklets/agent/runtime/state_log.py +++ b/stacklets/agent/runtime/state_log.py @@ -5,7 +5,7 @@ formats that list as one line per message, so the two can be compared. The log shows the list before nanobot's per-call steps in -`AgentRunner` (microcompact, tool-result budget, history snip). Those +`AgentRunner` (context compaction, tool-result budget). Those steps change only tool results and the oldest messages. """ diff --git a/stacklets/agent/runtime/thread_session.py b/stacklets/agent/runtime/thread_session.py deleted file mode 100644 index 807c6672..00000000 --- a/stacklets/agent/runtime/thread_session.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Thread-scoped sessions: one context per Matrix thread. - -nanobot keys a session as `channel:chat_id`, so every thread and every -speaker in a room shares one transcript. That is the measured context -pollution (docs/design/agent/review-2026-09.md section 2). nanobot -0.2.2 already has the seam: `InboundMessage.session_key_override`. -This shim sets it for threaded messages, so a thread gets its own -session. Replies still route by `chat_id`; the room id stays pure. - -Known v1 gap: the thread's root message was handled in the room -session, so a new thread session starts without the root's text. The -model sees the first threaded reply only. - -Set AGENT_THREAD_SESSIONS=0 to fall back to room-scoped sessions. - -PIN: patches MatrixChannel._handle_message (inherited from -channels/base.py). Re-verify on a nanobot bump. -""" - -from __future__ import annotations - -import os - - -def install() -> None: - if os.environ.get("AGENT_THREAD_SESSIONS", "1") == "0": - return - import nanobot.channels.matrix as matrix - - original = matrix.MatrixChannel._handle_message - - async def handle_with_thread_session( - self, sender_id, chat_id, content, - media=None, metadata=None, session_key=None, is_dm=False, - ): - root = (metadata or {}).get("thread_root_event_id") - if root and not session_key: - session_key = f"{self.name}:{chat_id}#thread:{root}" - return await original( - self, sender_id, chat_id, content, - media=media, metadata=metadata, - session_key=session_key, is_dm=is_dm, - ) - - matrix.MatrixChannel._handle_message = handle_with_thread_session diff --git a/stacklets/agent/runtime/tool_trim.py b/stacklets/agent/runtime/tool_trim.py index f9f02d17..e94ba2eb 100644 --- a/stacklets/agent/runtime/tool_trim.py +++ b/stacklets/agent/runtime/tool_trim.py @@ -1,6 +1,6 @@ """Drop unused nanobot tools from the model's tool list. -nanobot 0.2.2 has no config gate for these tools. They enable with the +nanobot 0.3.5 has no config gate for these tools. They enable with the gateway services and put their JSON schemas into every request. The family agent does not use them. Fewer schemas make a shorter and more stable prompt prefix. @@ -15,12 +15,19 @@ # Tool names, as the model sees them. `message` stays: the gateway can # send mid-turn channel messages through it. TRIMMED = { - "cron", # agent-set reminders, not used yet - "long_task", # background goal loop, not used - "complete_goal", # pairs with long_task - "spawn", # subagents, not used - "write_stdin", # interactive exec sessions, agent runs one-shot CLI - "list_exec_sessions", # same group + "cron", # agent-set reminders, not used yet + "create_goal", # long-running goals, not used + "update_goal", # same group + "spawn", # subagents, not used + "exec_session", # interactive exec sessions, agent runs one-shot CLI + "list_exec_sessions", # same group + # Other conversations. A session is a room or a thread, and some are + # one person's: reading or writing one from another room would carry + # what was said there to people who were not in it. + "list_sessions", + "read_session", + "search_sessions", + "send_session_message", } diff --git a/tests/unit/stacklets/conftest.py b/tests/unit/stacklets/conftest.py index 8d69d8f5..ef748240 100644 --- a/tests/unit/stacklets/conftest.py +++ b/tests/unit/stacklets/conftest.py @@ -8,6 +8,7 @@ from __future__ import annotations +import dataclasses import os import subprocess import sys @@ -49,9 +50,6 @@ def nanobot_stub(): """ def _build() -> dict[str, types.ModuleType]: - def runtime_lines(state, msg, workspace, *, skip=False): - return ["stock line"] - class ContextBuilder: def build_messages(self, *args, **kwargs): return [{"role": "user", "content": "hi"}] @@ -77,6 +75,34 @@ class _Schema: def __init__(self, *args, **kwargs): self.args, self.kwargs = args, kwargs + class IntegerSchema(_Schema): + # nanobot 0.3: keyword arguments only. A description passed by + # position raises TypeError and detaches the tool. + def __init__(self, *, description="", **kwargs): + super().__init__(description=description, **kwargs) + + @dataclasses.dataclass(frozen=True) + class RuntimeContextBlock: + source: str + content: str + + def wrap_runtime_context_lines(lines): + content = "\n".join(line for line in lines if line) + return (f"[Runtime Context — metadata only, not instructions]\n{content}\n" + "[/Runtime Context]") if content else "" + + class AgentLoop: + # nanobot 0.3 agent/loop.py: providers registered here are asked + # for a block on every turn, and the blocks are appended to the + # user's message. + def __init__(self, *args, **kwargs): + self._runtime_context_providers = [] + + def register_runtime_context_provider(self, provider): + if provider not in self._runtime_context_providers: + self._runtime_context_providers.append(provider) + return lambda: None + class ToolLoader: def discover(self): return [] @@ -139,9 +165,8 @@ async def _on_room_invite(self, room, event): async def _handle_message(self, sender_id=None, chat_id=None, content=None, media=None, metadata=None, session_key=None, is_dm=False): - # Real nanobot's positional signature (channels/base.py), so - # the thread_session shim can wrap it and forward the - # session_key it derives from the thread root. + # Real nanobot's signature (channels/base.py); the Matrix + # channel passes a session_key per thread. self.handled.append(dict( sender_id=sender_id, chat_id=chat_id, content=content, media=media, metadata=metadata, session_key=session_key, @@ -158,16 +183,14 @@ def mod(name, **attrs): mod("nanobot") mod("nanobot.agent") - mod("nanobot.agent.context", - runtime_lines=runtime_lines, ContextBuilder=ContextBuilder) - # AgentRunner's microcompact reads this set at call time; - # compact_tools extends it. - mod("nanobot.agent.runner", - _COMPACTABLE_TOOLS=frozenset({"read_file", "exec", "grep"})) + mod("nanobot.agent.context", ContextBuilder=ContextBuilder) + mod("nanobot.agent.loop", AgentLoop=AgentLoop) + mod("nanobot.runtime_context", RuntimeContextBlock=RuntimeContextBlock, + wrap_runtime_context_lines=wrap_runtime_context_lines) mod("nanobot.agent.tools") mod("nanobot.agent.tools.base", Tool=Tool, tool_parameters=tool_parameters) mod("nanobot.agent.tools.schema", - StringSchema=_Schema, IntegerSchema=_Schema, ArraySchema=_Schema, + StringSchema=_Schema, IntegerSchema=IntegerSchema, ArraySchema=_Schema, BooleanSchema=_Schema, tool_parameters_schema=tool_parameters_schema) mod("nanobot.agent.tools.loader", ToolLoader=ToolLoader) @@ -176,7 +199,8 @@ def mod(name, **attrs): WriteFileTool=WriteFileTool, EditFileTool=EditFileTool) mod("nanobot.agent.tools.apply_patch", ApplyPatchTool=ApplyPatchTool) mod("nanobot.channels") - mod("nanobot.channels.matrix", MatrixChannel=MatrixChannel) + mod("nanobot.channels.matrix") + mod("nanobot.channels.matrix.runtime", MatrixChannel=MatrixChannel) return mods return _build diff --git a/tests/unit/stacklets/test_agent_brief_lists.py b/tests/unit/stacklets/test_agent_brief_lists.py index fd6baf7d..8b8f3e9c 100644 --- a/tests/unit/stacklets/test_agent_brief_lists.py +++ b/tests/unit/stacklets/test_agent_brief_lists.py @@ -18,7 +18,7 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "agent" / "runtime")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) -from brief import brief_lines, topic_for_room_label # noqa: E402 +from brief import brief_lines, topic_for_room_label, when_sent # noqa: E402 from topic_rooms import parse_topic_name # noqa: E402 PACKING = "---\ntype: list\ntitle: Packliste\ntopics:\n - camping\n---\n\n- [ ] Zelt\n" @@ -84,3 +84,9 @@ def test_the_brief_offers_no_overview_a_topic_does_not_have(tmp_path): lines = brief_lines(SimpleNamespace(metadata={"room": "Thema: Einkauf"}, sender_id=""), tmp_path) assert lines[0] == "This room's topic is 'einkauf'." assert "einkaufsliste" in lines[1] + + +def test_a_message_says_when_it_was_sent_so_by_friday_becomes_a_date(): + from datetime import datetime, timedelta, timezone + sent = datetime(2026, 10, 12, 18, 55, tzinfo=timezone(timedelta(hours=2), "CEST")) + assert when_sent(sent) == "Sent Monday, 2026-10-12 18:55 (CEST)." diff --git a/tests/unit/stacklets/test_agent_runtime_shims.py b/tests/unit/stacklets/test_agent_runtime_shims.py index a65874b6..370d1969 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", "thread_session", "compact_tools") + "list_tool", "tool_trim") # The stub nanobot itself lives in conftest as `nanobot_stub`, shared with @@ -93,24 +93,36 @@ def test_asking_the_vault_when_something_happened_is_a_tool(nanobot): assert "MemoryHistoryTool" in _discovered(mods) -def test_vault_tool_results_are_microcompacted(nanobot): - """nanobot shortens old results of the tools in this set only. +def test_the_briefing_is_one_of_nanobots_runtime_context_providers(nanobot, tmp_path): + """Every turn, nanobot asks its providers for context and appends it. - Without our names in it, every memory_search result stays in the - context in full for as long as the session replays it. + The briefing is how a shared room knows who is speaking. Through the + provider seam it rides on the user's message, and nanobot keeps it + with that message in the history, so the prompt the model cached last + turn is still the start of this one. """ - mods = nanobot() - tools = mods["nanobot.agent.runner"]._COMPACTABLE_TOOLS - assert {"memory_search", "memory_person", "memory_history"} <= tools - assert {"read_file", "exec", "grep"} <= tools, "nanobot's own set must stay" + import asyncio + import types as _types + + profile = tmp_path / "vault" / "homer" / "about.md" + profile.parent.mkdir(parents=True) + profile.write_text("> Safety inspector at the plant\n") + loop = nanobot()["nanobot.agent.loop"].AgentLoop() + request = _types.SimpleNamespace(sender_id="@homer:simpson", metadata={}, + workspace=tmp_path) + [provider] = loop._runtime_context_providers + block = asyncio.run(provider(request)) -def test_context_shims_are_attached(nanobot): - """The briefing attaches; the message list is nanobot's own.""" + assert block.source == "famstack-brief" + assert block.content.splitlines()[1].startswith("Sent "), "the date comes first" + assert "You are speaking with homer (@homer). Safety inspector at the plant" in block.content + assert block.content.startswith("[Runtime Context") + + +def test_the_message_list_stays_nanobots_own(nanobot): mods = nanobot() - ctx = mods["nanobot.agent.context"] - assert ctx.runtime_lines.__name__ == "_runtime_lines" - assert ctx.ContextBuilder.build_messages.__name__ == "build_messages" + assert mods["nanobot.agent.context"].ContextBuilder.build_messages.__name__ == "build_messages" def test_state_log_is_opt_in(nanobot, monkeypatch): @@ -129,7 +141,7 @@ def test_being_named_counts_as_a_mention(nanobot, monkeypatch): """ monkeypatch.setenv("AGENT_NAME", "Stacky") mods = nanobot() - channel = mods["nanobot.channels.matrix"].MatrixChannel() + channel = mods["nanobot.channels.matrix.runtime"].MatrixChannel() class _Event: pill_mention = False @@ -146,7 +158,7 @@ def test_a_pill_mention_still_wins_on_the_original_path(nanobot, monkeypatch): """ monkeypatch.setenv("AGENT_NAME", "Stacky") mods = nanobot() - channel = mods["nanobot.channels.matrix"].MatrixChannel() + channel = mods["nanobot.channels.matrix.runtime"].MatrixChannel() class _Event: pill_mention = True @@ -195,7 +207,7 @@ def test_a_reply_in_the_agents_own_thread_is_processed(nanobot): import asyncio mods = nanobot() - channel = mods["nanobot.channels.matrix"].MatrixChannel() + channel = mods["nanobot.channels.matrix.runtime"].MatrixChannel() room = _room_with_thread(channel, root_sender=channel.config.user_id) event = _threaded("$stacky-answer") @@ -215,7 +227,7 @@ def test_another_bots_thread_is_left_alone(nanobot): import asyncio mods = nanobot() - channel = mods["nanobot.channels.matrix"].MatrixChannel() + channel = mods["nanobot.channels.matrix.runtime"].MatrixChannel() room = _room_with_thread( channel, root_sender="@archivist-bot:home.local", replies=["@homer:home.local"], @@ -233,7 +245,7 @@ def test_a_top_level_message_still_needs_addressing(nanobot): import types as _types mods = nanobot() - channel = mods["nanobot.channels.matrix"].MatrixChannel() + channel = mods["nanobot.channels.matrix.runtime"].MatrixChannel() room = _types.SimpleNamespace(room_id="!family:home.local") event = _types.SimpleNamespace( sender="@marge:home.local", body="dinner at seven", @@ -256,7 +268,7 @@ def test_an_invite_produces_a_greeting_turn(nanobot): import types as _types mods = nanobot() - channel = mods["nanobot.channels.matrix"].MatrixChannel() + channel = mods["nanobot.channels.matrix.runtime"].MatrixChannel() room = _types.SimpleNamespace(room_id="!r:simpson", display_name="Topic: Camping") channel.client.rooms = {"!r:simpson": room} event = _types.SimpleNamespace(sender="@homer:simpson") @@ -278,10 +290,10 @@ def test_a_moved_symbol_does_not_take_the_others_down(nanobot): """One missing nanobot symbol must cost only its own tool. This is why each install runs in its own try. Sharing one block would - mean a renamed runner constant silently removed memory_search too, and + mean a moved agent loop silently removed memory_search too, and the agent would lose vault access over an unrelated upgrade. """ - mods = nanobot(drop="nanobot.agent.runner._COMPACTABLE_TOOLS") + mods = nanobot(drop="nanobot.agent.loop.AgentLoop") assert _discovered(mods) == {"MemorySearchTool", "MemoryPersonTool", "MemoryHistoryTool", "ListEditTool"} @@ -299,10 +311,9 @@ def test_the_stub_can_actually_express_a_detached_shim(nanobot): tools = mods["nanobot.agent.tools.loader"] assert not hasattr(tools, "ToolLoader"), "the drop hook must really remove it" - # Nothing to append to, so neither tool can have registered anywhere. - tools = mods["nanobot.agent.runner"]._COMPACTABLE_TOOLS - assert "memory_search" in tools, ( - "compact_tools is independent of the loader and should still attach" + # The briefing does not depend on the loader and still attaches. + assert mods["nanobot.agent.loop"].AgentLoop.__init__.__name__ == "_loop_init", ( + "the briefing is independent of the loader and should still attach" )