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>
135 lines
7.8 KiB
Markdown
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 `</think>`. 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.
|