Multi-turn conversations
Keep one Redactions store per conversation.
Pass earlier results as history, oldest first.
from jes import Redactions
redactions = Redactions(scope=conversation_id.encode()) # one per conversation
history = [] # earlier input and output Results, oldest first
incoming = guard.check_input(user_text, redactions=redactions, history=history)
reply = call_model(
incoming.onward,
# sanitized, not onward: an earlier reply's onward has PII restored.
[turn.sanitized for turn in history],
)
outgoing = guard.check_output(
reply,
prompt=incoming,
redactions=redactions,
history=history,
)
show(outgoing.onward) # the restored reply, or a refusal
history += [incoming, outgoing]
How placeholders work
The model only ever sees the placeholder.
- On input,
pii()replaces each detected value, and every other standalone occurrence of it, with a placeholder of the form[JES_PII_<22 characters>]. The same value always gets the same placeholder within one store. - On output, a placeholder is restored only when the store issued it, the model saw it in this generation's context, and it appears exactly in the reply.
- On a tool call, PII in the arguments blocks the call. Placeholders are never restored into arguments.
- PII that first appears in the model's reply is hidden before any judgment
backend sees it. With the default
output_mode="flag", judges see a per-check[JES_LOCAL_…]marker, and the value is put back inonwardand flagged.output_mode="redact"replaces it with[REDACTED_{ENTITY}]for good, and"block"blocks. - Placeholder syntax typed by a user, or found in retrieved text, is never
trusted. jes rewrites it as literal text and flags
placeholder_in_text.
Without a store
If you don't pass redactions, check_input creates a fresh in-memory store
and keeps it on Result.redactions. check_output(prompt=incoming) then
uses that same store. That is enough for a single turn.
If you pass an explicit store whose identity differs from the one on the
result, jes raises RedactionError instead of picking one.
Persisting a store
Stores refuse pickle and copy. To persist one between requests,
encrypt it with a 32-byte key (needs jes[crypto]):
blob = redactions.dumps(key, associated_data=b"tenant-42")
# … later …
redactions = Redactions.loads(
blob, key, scope=conversation_id.encode(), associated_data=b"tenant-42"
)
loads needs the same key, scope, and associated data. Key rotation and
replay protection are your responsibility.
Output rules
- Buffer the model's output and call
check_outputonce with the complete reply. jes does not check or restore a stream. - Restoration produces plain text. Escape values before rendering Markdown, HTML, JSON, or passing them to a shell.
- jes never restores tool-call arguments. Parse, authorize, and populate tool inputs yourself.
- A placeholder inside a URL is restored only for origins listed in
pii(restore_origins=[...]). Otherwise it stays in place and getsplaceholder_in_url, which blocks by default.
The runnable version is PII across one conversation cookbook page.