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

7.8 KiB

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.