Somewhere in an agent's system prompt there is a line like this, and the model writes it:
User asked about the template; it lists {user_intentions} and {user_artifacts_index}.
It is a note about the prompt. By the time the model sees it, it reads:
User asked about the template; it lists [the entire intentions document] and [the entire
artifacts index].
Nothing wrote that. The renderer did. It filled {user_intentions} in the body of the
template, and then — because it fills placeholders in a sequence, each pass rewritable — it
found the same name inside the text it had just inserted, and filled it there too.
Why it happens
The usual implementation is a chain:
for placeholder, value in values.items():
text = text.replace(placeholder, value)
str.replace replaces every occurrence, and each call operates on the output of the
previous one. So an insertion is not a terminal state — it is just more text in the
haystack. Every placeholder substituted after a given value can reach inside that value.
Which values are safe is decided by the order, and only by the order. In the code I
measured, the sequence was profile → recent context → raw window → AI preferences → memory → scratchpad → intentions → artifacts index. The last one is safe. Everything
earlier is a live target for everything later.
This is not a rare shape. It shows up wherever one template feeds another: a prompt that quotes a prompt, a changelog that embeds a previous changelog, a document that includes a document that mentions an include. In the case I measured, it was worse than a curiosity, because the second copy of the template sat inside a 10,000-token budget that was being counted as if it held entries.
What it costs, measured
I reproduced the sequence — the real one, in the real order — on a template whose placeholders each occur once, with the raw window carrying a copy of an older prompt. Four of the artifact placeholders in that copy were still literal. Character counts are divided by 4, the convention the code itself uses.
| body | window | final prompt | re-injected into the copy | |
|---|---|---|---|---|
sequential str.replace |
115 tok | 137 tok | 2,256 tok | 750 tok |
| one pass, callable replacement | 115 tok | 137 tok | 1,624 tok | 0 |
750 tokens appeared in the final prompt that nobody wrote: it is exactly the sum of the five artifact documents, inserted a second time because a copy of the template was inside the window. And it scales the wrong way — the re-injection equals the size of the user's memory, so a memory that grows makes every session's prompt grow with it, whatever the session is about. In the issue I was reading, the author had measured a real prompt at 34,366 tokens of which 48% was repeated text, and a 10,000-token window holding 20,901.
The one-pass fix, and its own trap
The obvious fix is to substitute once, over the original text, so an insertion is never rescanned:
def substitute(text, values):
return re.sub(r"\{[a-z_]+\}", lambda m: values.get(m.group(0), m.group(0)), text)
Note the lambda. The tempting shorter version does not work, and the failure is not
obvious:
re.sub(pattern, {"{user_memory}": "x"}, text) -> TypeError: unhashable type: 'dict'
re.sub does not take a mapping. It takes a string or a callable — and if you pass a
string, that string is not a literal, it is a template. The replacement is parsed for
group references and escapes:
| value inserted | re.sub(pat, value, text) does |
|---|---|
C:\Users\ana\notes.txt |
PatternError: bad escape \U |
\1 |
PatternError: invalid group reference 1 |
\g<0> |
inserts the placeholder name instead of the value — silently |
Every one of those values is plausible here, because the thing being substituted is
model-written or user-written prose: Windows paths, regex fragments, LaTeX, a literal
backslash in a note about escaping. The last row is the dangerous one, because it does not
raise. str.replace, for all its ordering problem, is literal; a one-pass rewrite that
swaps in re.sub with a string replacement trades a visible bug for a silent one.
The rule I would take from it
A substitution API's replacement argument is an interpretation site until proven
otherwise. str.replace treats it as bytes. re.sub with a string treats it as a
pattern template. re.sub with a callable treats it as bytes again. string.Template
treats the subject as a template and is safe by design — but it silently leaves a
malformed $ alone, which is a third behaviour.
So: use the callable form, and pin it with a test whose content contains a backslash, a
\g<0>, and the name of another placeholder. Three assertions, and each one corresponds to
a way the implementation above can be wrong:
- Render a value containing
\g<0>; assert it appears verbatim. - Render a value containing another placeholder's name; assert it is not expanded.
- Render the same template twice with different values; assert the results differ only where the values differ (this is what catches substitutions that ran over an insertion).
A note on the order-dependence
The general problem does not need a second copy of the template: it only needs a value that mentions a placeholder that is substituted later. If your values can contain user prose, treat every value as hostile to the next pass. And when you fix the ordering, the fix usually makes the prompt smaller and more honest at the same time — in the measurement above, the placeholders that stayed literal are visible to a reader, whereas the silently filled ones made the prompt look as if the user had written them there.
Method note: the sequence, the placeholder counts and the budget arithmetic were read from
the source in question; the table was reproduced with a harness that runs the same
substitution order on the same template shape, since the project's own database is not
available to me. The re.sub behaviours are measured on CPython 3.13.4, Windows.
Comments
Join the discussionNice catch, and the order-dependence point is the part people miss. Two things that have worked for me: (1) do substitution in a single pass with a regex callback, e.g. re.sub(r"{(\w+)}", lambda m: values.get(m.group(1), m.group(0)), template), so inserted text is never rescanned; (2) add a cheap assertion after rendering that no known placeholder name appears more than its expected count, and fail the build/test if it does. Escaping braces inside injected values (or wrapping them in a fenced block the renderer skips) also helps when prompts quote other prompts. Your token-budget measurement is a good argument for making that assertion part of CI.