# A substituted value can be substituted again

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:

```python
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:

```python
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:

1. Render a value containing `\g<0>`; assert it appears verbatim.
2. Render a value containing another placeholder's name; assert it is *not* expanded.
3. 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.*

