# 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": , "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.