Do real models keep placeholders intact?¶
Taintward sends <PERSON_1>, <EMAIL_1>, <SSN_1> to the model instead of the
real values, and swaps the real values back into the answer and the tool call
arguments. That only works if the model writes the placeholders back in a form
Taintward can find. Until now every test used fake models. This page measures
real ones.
All data in the tasks and on this page is fake.
How it was measured¶
bench/placeholders/ runs 47 small agent tasks through the real product path:
an OpenAI client patched with protect_openai, pointed at Ollama's OpenAI API
(and once through a protected LangChain ChatOpenAI). The app side acts like a
real app: it keeps the history, runs the fake tools with the arguments it gets
back, and sends the restored answers back in the next turn.
Task groups: emails and letters (8), summaries of records (6), JSON extraction
(6), tool calls such as send_email(to, body) and lookup_customer(ssn) (8),
multi-turn chats (5), translation into Spanish, French, German, Hindi,
Japanese and Chinese (6), lists of several people (4), code with a key (4).
Every HTTP request and response is recorded, so the harness sees what really left the process and what the model really wrote. Per task it checks:
- restore ok: the final answer has the right real values, JSON fields and tool arguments are right, and no placeholder is left over.
- placeholders back: placeholders the task needed that came back in a form Taintward restored (anywhere in the answers or tool calls).
- copied exactly: of those, how many the model wrote exactly as
<PERSON_1>. - rewritten, not restored: the model wrote a changed form (
[PERSON_1],Person 1) that was not restored. dropped: the model left it out. - wrong restore: a slot got a different real value and the model did not ask for it (a Taintward bug). model mix-up: the model itself used the wrong placeholder (for example the other person's email).
- raw value sent: a value Taintward knew was in a request. Must be 0.
- failures flagged: failed tasks where the new check put
taintward.placeholders.lost/.unknownon the span.
Modes:
- baseline: Taintward 0.2.0 behaviour (exact restore only, no instruction).
- baseline answers + tolerant restore (offline): the same saved baseline answers restored again with the new rules. No new model calls, tool tasks kept as they were. This shows what the restore rules alone give.
- hardened: the new defaults (instruction, tolerant restore, the check).
Models: qwen2.5:3b, llama3.2:3b, qwen2.5-coder:7b, temperature 0, on a
laptop CPU. OpenAI and Anthropic runs are supported but were skipped (no keys).
Results¶
| model | mode | restore ok | placeholders back | copied exactly | rewritten, not restored | dropped | tool args ok | wrong restore | model mix-up | leftover | raw value sent |
|---|---|---|---|---|---|---|---|---|---|---|---|
| qwen2.5:3b | baseline | 25/47 | 69/92 | 69/92 | 11 | 12 | 0/9 | 0 | 0 | 17 | 0 |
| qwen2.5:3b | tolerant restore only | 29/47 | 77/92 | 69/92 | 3 | 12 | 0/9 | 0 | 0 | 10 | 0 |
| qwen2.5:3b | hardened | 37/47 | 83/95 | 74/95 | 0 | 12 | 7/9 | 0 | 1 | 0 | 0 |
| qwen2.5:3b (LangChain) | hardened | 37/47 | 83/95 | 74/95 | 0 | 12 | 7/9 | 0 | 1 | 0 | 0 |
| llama3.2:3b | baseline | 9/47 | 32/92 | 32/92 | 30 | 30 | 0/9 | 0 | 0 | 26 | 0 |
| llama3.2:3b | tolerant restore only | 21/47 | 61/92 | 32/92 | 3 | 28 | 0/9 | 0 | 0 | 12 | 0 |
| llama3.2:3b | hardened | 38/47 | 87/95 | 80/95 | 0 | 8 | 6/9 | 0 | 2 | 0 | 0 |
| qwen2.5-coder:7b | baseline | 23/47 | 66/92 | 66/92 | 15 | 11 | 0/9 | 0 | 0 | 9 | 0 |
| qwen2.5-coder:7b | hardened | 33/47 | 86/92 | 86/92 | 2 | 4 | 0/9 | 0 | 0 | 1 | 0 |
(The needed count moves from 92 to 95 because in a few tool tasks the tool result brought new people into the run. The offline row is missing for qwen2.5-coder because its baseline was saved before answers were kept.)
By task group, restore ok, baseline -> hardened:
| model | summary | json | tool | multi | translate | list | code | |
|---|---|---|---|---|---|---|---|---|
| qwen2.5:3b | 3 -> 4 /8 | 5 -> 6 /6 | 5 -> 6 /6 | 0 -> 6 /8 | 1 -> 4 /5 | 6 -> 6 /6 | 3 -> 1 /4 | 2 -> 4 /4 |
| llama3.2:3b | 0 -> 6 /8 | 1 -> 5 /6 | 2 -> 6 /6 | 0 -> 5 /8 | 0 -> 3 /5 | 4 -> 6 /6 | 0 -> 3 /4 | 2 -> 4 /4 |
| qwen2.5-coder:7b | 1 -> 4 /8 | 4 -> 6 /6 | 6 -> 6 /6 | 0 -> 0 /8 | 2 -> 3 /5 | 5 -> 6 /6 | 3 -> 4 /4 | 2 -> 4 /4 |
No run sent a raw value that Taintward knew, and no run had a wrong restore.
What the baseline showed¶
- Tool calls never worked with Ollama (0 of 9 for every model). Ollama
writes tool arguments as JSON with
<escaped as<:{"to":"<EMAIL_1>"}. The old restore looked for<in the raw string, found nothing, and the tool got the text<EMAIL_1>. This was a plain bug, now fixed for both modes. - Models rewrite placeholders. Seen forms:
[PERSON_1], barePERSON_1andAWS_KEY_1(very common in code and JSON),Person 1 - Email 1,[Person_1],\[PERSON_1](markdown),<<EMAIL_1>>, and translated labels such as<名前_1>and<メールアドレス_1>in Japanese. - Models treat placeholders as blanks or as private data. They write
Dear [Recipient's Full Name]orDear Emily Johnsoninstead of<PERSON_1>, or refuse: "I can't write a letter that includes someone's social security number" when they only ever saw<SSN_1>. - Numbers follow reading order backwards. In "Wei Chen ..., Priya
Sharma ..." Wei gets
<PERSON_2>and Priya<PERSON_1>(the redactor replaces spans from the end). Some models then reorder lists by number, and in one task llama sent the invoice to<EMAIL_2>when<PERSON_1>had asked.
What changed¶
- Placeholder instruction (
Ward(placeholder_instruction=True), the default). When a request has placeholders, one short system line is added: it names the placeholders, says they stand for real values the user gave (not blanks, safe to repeat), and asks the model to copy them exactly, also in JSON, code, tool arguments and translations. It goes into the first system message (or a new one) for OpenAI chat,instructionsfor the Responses API,systemfor Anthropic, and aSystemMessagefor LangChain. This gave the biggest gain, mostly by cutting refusals and template blanks. - Tolerant restore (
PlaceholderMap(tolerant=True), the default): - other brackets:
[X_1],{X_1},{{X_1}},(X_1),«X_1»,<X_1>,【X_1】, markdown escapes\<X\_1\>and\[X_1]; - case and separators inside brackets:
<Person 1>,<PERSON-1>,<Person1>; - bare
PERSON_1/Person_1(upper or title case only;person_1could be a variable name, so it is never restored); Person 1with a space, only when the same answer has no real placeholder of that label and everyPerson Nin it is in the map (otherwise it is probably a list number);- translated labels (
<PERSONA_1>,<NAME_1>,<名前_1>, ...), only when the alias is not a real label in the map; - JSON strings are parsed first, so
<EMAIL_1>is restored. A form is only restored when it names a placeholder in this run's map, so[Step 1],Table 2or an invented<PERSON_7>are never touched. - Check after restore.
check_outputlooks at the model's answer before restore and recordstaintward.placeholders.repaired,.lost(a known placeholder in a form that is not safe to restore, like<PERSON>with no number) and.unknown(a placeholder not in the map) on the span, with placeholder forms only, never values. It logs a warning, and withWard(placeholder_strict=True)it raisesPlaceholderRestoreError.
What still fails¶
- Dropped values (8 to 12 per model). The model writes
Dear [Full Name], leaves the email out, or refuses ("I can't repeat your SSN"). Restore cannot bring back what the model never wrote, and the check does not see it, which is why "failures flagged" stays low: the remaining failures are drops, not broken placeholders. - qwen2.5-coder through Ollama writes tool calls as JSON text in the answer instead of real tool calls, so every tool task fails for it. Not a placeholder problem.
- Model mix-ups (1 to 2 per model): the wrong person in a list or the wrong
email in
send_email. Restore was right; the model picked the wrong placeholder. Numbering placeholders in reading order (a change inredact.py) would likely help and is left as a follow-up. - Detector misses (4 values over the 47 tasks): some names, for example "Lukas Becker" in a table, are not detected and go to the model as they are. This is a detection issue, counted separately, not a placeholder issue.
- Forms such as
[Name of Person 2]or<YOUR_AWS_KEY_1>are left alone on purpose: they could mean something else.
Small local models are a hard case. Larger hosted models will likely do better, and the harness can measure that as soon as keys are set.
How to run it¶
The real-model runs are slow and are not part of pytest.
ollama pull qwen2.5:3b
python bench/placeholders/run.py --provider ollama --model qwen2.5:3b --mode baseline
python bench/placeholders/run.py --provider ollama --model qwen2.5:3b --mode hardened
python bench/placeholders/run.py --provider langchain --model qwen2.5:3b # needs langchain-openai
OPENAI_API_KEY=... python bench/placeholders/run.py --provider openai --model gpt-4o-mini
ANTHROPIC_API_KEY=... python bench/placeholders/run.py --provider anthropic --model claude-haiku-4-5
python bench/placeholders/run.py --replay # baseline answers + tolerant restore, offline
python bench/placeholders/run.py --report # the tables on this page
A provider without its key (or without a running Ollama) is skipped with a
message. Results are saved in bench/placeholders/results/. A small offline
smoke test of the harness with a fake model is in
tests/test_placeholders_robust.py.