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
22 changes: 22 additions & 0 deletions docs/adr/adr-012-nanobot-fork.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 12 additions & 2 deletions stacklets/agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions stacklets/agent/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"defaults": {
"model_preset": "primary",
"max_tool_iterations": 12,
"max_messages": 40,
"disabled_skills": ["memory", "my"]
}
},
Expand All @@ -29,6 +28,9 @@
"image_generation": {"enable": false}
},
"channels": {
"sendProgress": false,
"sendToolHints": false,
"showReasoning": false,
"matrix": {
"enabled": true,
"homeserver": "${AGENT_MATRIX_HOMESERVER}",
Expand All @@ -37,6 +39,7 @@
"e2eeEnabled": true,
"allowFrom": ["*"],
"groupPolicy": "mention"
}
},
"websocket": {"enabled": false}
}
}
45 changes: 20 additions & 25 deletions stacklets/agent/runtime/README.md
Original file line number Diff line number Diff line change
@@ -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 `[<tool> 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`.
Expand Down Expand Up @@ -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`.
12 changes: 12 additions & 0 deletions stacklets/agent/runtime/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 0 additions & 28 deletions stacklets/agent/runtime/compact_tools.py

This file was deleted.

2 changes: 1 addition & 1 deletion stacklets/agent/runtime/history_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
Expand Down
1 change: 0 additions & 1 deletion stacklets/agent/runtime/memory_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
nullable=True,
),
limit=IntegerSchema(
5,
description="Maximum number of results to return (at most 8).",
minimum=1,
maximum=8,
Expand Down
Loading
Loading