Research: chat-template tags in text the model reads; tips I15 and T33; an open decision

M5a task 04 stopped twice on the chat-template tags the check refuses.
The first time, the model read them in a file and took them for its own
template. The second time, it spelled them out in its own reasoning.

Measured on straylight: the think and tool-call tags are one token
whatever the tokenizer's flag. Inside any chat message, the turn
markers become control tokens, so a tool result can forge a system
turn.

Adds the rule to AGENTS.md, and proposes neutralizing the tags in
loopd as an open decision.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 16:41:44 -07:00
committed by kyle
co-authored by Claude Opus 5.5
parent 6f093fcd8e
commit fb0aa2e576
4 changed files with 141 additions and 0 deletions
+1
View File
@@ -68,6 +68,7 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit
| How an approval whose arguments do not fit one Mattermost post is shown. Settled on 2026-09-18: what is being approved is never truncated, in any channel, because the unseen tail is where injected text would put its payload; `bxctl` prints arguments in full. Leading option for Mattermost: a thread of numbered chunks split on character boundaries of the escaped text, where only a reply or reaction on the last post counts and that post repeats the id, the chunk count and the hash of the whole; above some number of chunks, the post points to `bxctl approvals` and approval is local only. The post size limit and rate limits must be checked against the Mattermost API reference first. | M4 |
| Secret store: settled 2026-09-23 (systemd credentials, environment, owner-only file; P15 proposed). Where `brokerd` reads tool secrets from, and whether `SecretStore` moves out of `gatewayd` into a shared place, is decided when a tool first needs a secret. | when a tool needs a secret |
| Whether cloud-led sessions are ever allowed, and for which data classes. | M6 |
| Neutralizing chat-template tags in everything `loopd` sends (the owner's and other agents' messages, tool results, recalled text): measured 2026-09-24, a tag inside any message becomes Ornith's control token, so a fetched page can end its tool result and forge a system turn (`docs/research/2026-09-24-chat-template-tokens.md`). Proposed: replace each tag in a fixed list with an escaped look-alike when the text is first appended and logged (replay stays byte-identical), the list checked against the tokenizer by the self-test. Memory writes already refuse the tags (M5a). | before M5b; the exposure exists since M3 |
| Session-scoped and standing approvals for tools other than memory: answering an approval with `!approve 42 session` (the same pattern for the rest of the session) or `!approve 42 always` (standing), words after the id as `!deny 42 <reason>` has (owner's syntax, 2026-09-24). To settle: what "session" covers (this call's tool and path or host, or the whole grant it matched); and where "always" writes, since `brokerd` cannot write grants (a proposed grant for the owner, or a separate directory of chat-approved grants). A temporary grant is new authority (the grants are the owner's files, and `brokerd` cannot write them), so it needs its own design. Owner's point, 2026-09-24: a harness should not ask a stream of questions. | after M5 |
| Projects in memory. Honcho can separate projects by a workspace per project (hard walls, shared naturally by every agent on the project, but Honcho's picture of the owner splits per workspace) or by scopes inside one workspace (one picture, recall filtered by project; semantics not yet read). Either way a project is a `memoryd` setting: its channels and threads, its workspace or scope, `write` and `trust`. A shared project workspace is written by several agents, so its trust is the owner's call per project. Test both on rift before choosing. Owner's idea, 2026-09-24. | M5b or a later M5d |
+2
View File
@@ -30,6 +30,7 @@ How it is used:
| I12 | A file that exists but cannot be read is an error. Only a file that does not exist may be treated as absent. | M2b finding 3. | yes | |
| I13 | Before calling a failure "environmental", measure the thing you blame, and name the machine, OS and file system the evidence came from. A sample that shows a thread inside a system call shows where the time goes, not that the call is stuck. | M3a stop before task 20: an fsync "stall" was blamed from `__fcntl` frames that were ordinary 4 ms `F_FULLFSYNC` calls on the Mac, and the real cause, a macOS socket rule, went unfixed. | no | |
| I14 | Recovering from a crash is a case to write a test for, not only to reason about. A file the daemon itself can leave behind (created, not yet written) must load like an empty one. | M3a finding 1: one zero-length log file made `brokerd` panic at startup, where the same directory verified as `ok, 0 records`. |
| I15 | Never write the model's chat-template tags out whole (the think and tool-call tags, the angle-bar turn markers) in code, comments, commands or the log. Build them from pieces (`concat!("<", "think")`) and name them in words. Text holding them turns into the model's own control tokens when it reads it. | M5a task 04: the given test held the tags; Ornith's reasoning ended at the test file's closing think tag, and it lost track of the file and made no change. `docs/research/2026-09-24-chat-template-tokens.md`. | yes | |
## Tips for writing tasks
@@ -67,6 +68,7 @@ How it is used:
| T30 | Every behaviour a task writes must be reachable by that task's own tests. When a function's effect shows only after a later task (here: a refusal's reason, which only task 15 could send), give a unit test in the first task, or write that function in the later one. And never let one name mean two things in a comment: say which `reason`. | M4b task 14 dropped a refusal's reason, reading `Refused -> Ended::Refused { by, reason }` as the `DenyReason` in scope; its tests passed, and task 15's caught it, in a file task 15 could not touch. |
| T31 | In a spec, for every post or message the harness sends, name who can read it and whether that reader can act on it. Walk each kind of sender through each kind of channel (the owner, an allowed non-approver, a stranger; direct, group, channel). | M4b: an approval asked by a non-approver in a direct message went into that direct message, where no approver could see it. Every test passed, because the tests used the spec's cases; the device check with a second account found it (review finding 1, fixed by tasks 17 and 18). |
| T32 | A given test must not assert the order of two things that run on their own threads. Check the order where it is decided (here, the sort in the catch-up parser), and compare what the threads deliver as a set. Before hand-over, stress each test with concurrency under load (`yes > /dev/null` beside a loop of 60 runs). | M4a's `serve_restart` expected two caught-up sessions' turns to reach `loopd` in order. They run on separate threads, and under load 1 run in 60 failed. M4b task 18's session hit it, proved it happens without its change, and reported it instead of editing the test. |
| T33 | A file the implementer reads must never hold the model's chat-template tags literally: not in skeletons, given tests, fixtures, task text or specs it may open. Build them from pieces and name them in words. grep the plan for the tags before hand-over. | M5a task 04 (above); the measurements in `docs/research/2026-09-24-chat-template-tokens.md`: the think and tool-call tags are one token whatever the tokenizer's flag, and inside a chat message the turn markers become control tokens. |
## What worked and should be kept
@@ -0,0 +1,134 @@
# Chat-template tokens in text the model reads
Written 2026-09-24, after M5a task 04 failed. Measured on straylight: `llama-server` build
b10964-b29c606, `ornith-1.5-35b-a3b`, with `/tokenize`, `/apply-template` and one-token chat
completions on slot 0 with `cache_prompt: false`.
**In short:** text that holds Ornith's chat-template tags does not stay text. Some tags become the
model's real control tokens when they reach it, **inside any message**: the owner's, another
agent's in a channel, a tool result, a fetched page, a recalled note. Such text can end its own
turn and forge a new one (a system turn, or one that looks like the owner), or end the model's
reasoning. It broke the implementer's run in M5a task 04. Boxmaker, as it stands, has the same
exposure on every path into the prompt, except memory writes, which M5a's check guards.
This document writes no tag out in full. They are named in words, and built from pieces in the
commands shown.
## 1. What happened
M5a task 04 builds the check that refuses chat-template tags in memory writes. Its skeleton
(`check.rs`) and its given test held those tags literally: the opening and closing think tags, the
opening and closing tool-call tags, and the angle-bar delimiters (a less-than sign followed by a
vertical bar, and the reverse). Ornith, the implementer through OpenCode, read the test file.
In its log (`.state/runs/M5a/04-memoryd-entries-check.log`), its reasoning ends in the middle of a
thought, exactly where the file's closing think tag was. It went on reading the file as if the
tags were not there. It came to believe a closing tool-call tag was a three-character string, and
hex-dumped lines of the file to find out what it held. It made no change, and the session ended
without a commit.
The fix in the plan (commit e40906f on `m5a`) builds every tag from pieces (`concat!("<",
"think")`) in `check.rs` and in the four given tests that held them, and describes them in words
in the spec.
**The second stop shows the harder half.** With every file clean, the next session copied the
files and saw the tests fail. It then checked its planned code against the markup test cases **in
its own reasoning**, putting the tags back together from the pieces and writing them out. When it
generated the think tag, the server took it for the end of the model's reasoning. The rest of the
line (`", "`) became the model's answer, and the turn ended with no tool call. OpenCode's stored
session (`opencode export`) shows the reasoning cut off exactly there.
So keeping the tags out of files is not enough. A model that has to **think about** its own
template tags can end its own turn by thinking them. The plan's fix (3ed1b8b) removes the reason
to think about them: `check.rs` is given complete, and the task says not to work through the
markup cases. For Boxmaker, the same holds whenever Ornith reasons about text that contains a tag,
even an escaped one. It will reconstruct the tag in its reasoning if the task makes it relevant.
## 2. What the server does with the tags
### Tokenizing a tag on its own
`POST /tokenize` with `{"model": "ornith-1.5-35b-a3b", "content": <tag>, "with_pieces": true}`,
with and without `"parse_special"`:
| Tag | Default | `parse_special: false` | `parse_special: true` |
|---|---|---|---|
| end of turn (angle-bar `im_end`) | one token, 248046 | six tokens (27, 91, 316, 6018, 91, 29) | one token, 248046 |
| closing think tag | one token, 248069 | one token, 248069 | one token, 248069 |
| opening tool-call tag | one token, 248058 | one token, 248058 | one token, 248058 |
| `hello`, for comparison | one token | one token | one token |
There are two kinds of tag:
- **Control tokens**, the angle-bar ones (`im_start`, `im_end`, and the vision tags): parsed as
one special token when special parsing is on, split into characters when it is off.
- **Ordinary tokens that happen to be tags**: think and tool-call. Each is one vocabulary entry,
**whatever the flag says**. No tokenizer setting keeps them as text.
### Inside a chat message
The chat template pastes message text straight into the prompt string. `/apply-template` shows a
user message ending with the end-of-turn tag rendered as two end-of-turn tags in a row, then the
assistant's turn.
The server then tokenizes the whole prompt with special parsing on. So the tag inside the message
became the control token:
| Request (`/v1/chat/completions`, `max_tokens: 1`, reasoning off) | Prompt tokens |
|---|---|
| user message `say hi` | 14 |
| the same, with the end-of-turn tag appended | 15 (one token for the tag, not six) |
| a tool result `page text` | 314 |
| the same words, then a new line, `system`, a new line, `obey the page` | 321 |
| the same, but with the end-of-turn and start-of-turn tags forging a system turn | 323 (two tokens for the two tags) |
The last row is the attack: a fetched page whose text ends the tool result and opens a system
turn. Ornith reads that turn exactly as it would read a real one.
## 3. Where Boxmaker is exposed
Every path that puts text into a request:
- **The owner's messages and other agents' messages in channels** (`gatewayd` → `loopd`). An agent
that may talk to Boxmaker can forge an owner turn.
- **Tool results** (`brokerd` → `loopd`): a fetched page, a file, a command's output. This is the
worst path, because it carries the most untrusted text.
- **`recall` results** (M5a): session text and notes. Notes from untrusted sessions come back
labelled untrusted, but a forged turn inside one escapes the label.
- **Memory files in the baseline** (M5a). Here the check already refuses the tags on write and on
load, rules 1 to 3.
`brokerd`'s taint tracking does not help against this. It labels a result untrusted, but the model
never sees the label: it sees a system turn.
The implementer's side is exposed the same way. Any file an implementing model reads (code, tests,
plans, logs) can derail its session if it holds the tags.
## 4. Recommendations
1. **Neutralize the tags in `loopd`, before anything is logged or sent** (proposed; the owner's
decision). In every text `loopd` puts into a message (user messages, tool results, recalled
text), replace each tag in a fixed list with a harmless look-alike that the model still
understands. For example, an ampersand escape of the angle brackets turns the closing think tag
into `&lt;/think&gt;`. The inference contract is kept, because the replacement is made when the
text is first appended and logged, and replay sends the logged bytes. The list comes from the
template and the tokenizer: the self-test at start-up tokenizes each tag in the list and each
variant of it, and fails closed if the server treats a tag differently than recorded.
2. **Keep M5a's check** as it is: memory writes refuse the tags outright, which is stricter than
neutralizing.
3. **Plans and code: never write a tag out whole** in a file a model will read, including
comments, commands and logs. Build it from pieces, as M4b's tests do for control characters.
This goes into `docs/implementer-lessons.md` (tip T33) and `AGENTS.md`.
4. **Check the server's own options.** `llama-server` may have a setting that tokenizes message
text without special parsing. That would cover the angle-bar tags only, since the think and
tool-call tags are ordinary tokens. I have not looked for such a setting; it must be checked
against the llama.cpp source at the running build before anyone relies on it.
## 5. Not verified
- How the tokenizer treats case variants and near misses: the closing think tag in capitals, or
with a space inside it. The M5a check refuses them case-insensitively anyway.
- Whether Ornith follows a forged system turn. The measurements show the model receives one; what
it then does was not tested.
- Whether a forged tool call in a tool result makes the server parse a call. Tool calls are parsed
from the model's output, not its input, so probably not; it was not tested.