Files
boxmaker/docs/research/2026-09-24-chat-template-tokens.md
T
kyleandClaude Opus 5.5 fb0aa2e576 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>
2026-09-25 16:41:44 -07:00

135 lines
7.8 KiB
Markdown

# 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.