Change: This is S-3 of the agent prompt-caching build (Epic-2050,
RFD docs/notes/rfd-agent-caching-strategy.md §5.1 D-1 / §5.2 D-2). S-1
made the per-seat cache policy config; S-2 built the provider wiring
(model_settings(seat), cache_point(seat), session_id).
S-3 is the first consumer: it rebuilds Mira's converse user prompt into a
cacheable sandwich — a byte-stable prefix, then a volatile per-turn suffix —
and enables the S-2 wiring on the converse call site.
Before: every converse turn minted a fresh per-run nonce
(secrets.token_hex(3)) and wrapped everything in it — including the
frame, which is identical turn to turn. So the delimiter tags around the frame changed every
turn, which meant no two turns shared a byte-identical prefix, which meant the
provider prompt cache could never hit. We were burning the frame + instructions as a fresh
cold write on every single turn (RFD §5.2: "today's per-run nonce burns the cache").
| File | Role |
|---|---|
agents/mira/converse.py | The sandwich. build_converse_prompt(ci) now returns an ordered list[str | CachePoint]: a stable prefix (meta + frame + bundle) with breakpoints A/B, then the volatile suffix. The per-run nonce now wraps only the suffix. The call site swaps ZDR_SETTINGS for model_settings("converse", session_id=...). |
agents/mira/models.py | ContextBundle (an envelope wrapper: verbatim text + version_hash), and three additive MiraConverseInput fields — frame_version, bundle, session_id — accepted from agent_invoke.v1. |
agents/mira/tests/test_converse.py | The S-3 gate: byte-identical prefix across two consecutive turns; breakpoint placement (2 with a bundle, 1 without); the frame-only path; the content-hash fallback when no frame_version is tracked. |
Stability strictly decreases top to bottom; cache breakpoints sit at the stability
boundaries. CachePoint markers come from cache_point("converse")
(S-2) — None when the seat is cold, so an off/ambient-suppressed seat never drifts
a marker into the sequence.
1. matter meta line (changes ~never) ┤ 2. current_frame @ frame_version (bundle-stable delimiter) │ stable prefix ← CachePoint (breakpoint A) — shields frame from a mid-session bundle refresh 3. context bundle @ version_hash (verbatim; frame-only until OQ-4) ← CachePoint (breakpoint B) — extends the cache over the bundle ──────────────────────────────── ┘ 4. conversation history (grows per turn) ┤ per-turn suffix 5. counsel message (volatile, untrusted) │ NEVER cached; the per-run 6. reply instruction ┘ nonce lives ONLY here now
The nonce did two jobs at once: injection defence and block delimiting. S-3 splits them by what the block is:
<<COUNSEL_MESSAGE_{nonce}>>.
This is the live injection surface, so it stays exactly as strong as before.<<MATTER_FRAME_v{frame_version}>> and
<<CONTEXT_BUNDLE_{version_hash[:12]}>>. The tag changes exactly when
the content changes — so the bytes are identical across turns (cache hits), and an attacker
buried in document content cannot forge a closing tag whose value is a hash of their own
bytes.Why this is not a weakening (RFD §5.2 security note): the
nonce defends against untrusted text escaping its delimiter. The frame and bundle are
engine-produced JSON whose hash-derived tag is unpredictable to the adversarial content inside
them; the live injection surface — counsel free-text and raw history — keeps the per-run nonce.
The adversarial corpus proves it: the one converse fence-escape case injects through the counsel
message, so strip_nonce still flips it RED; the indirect frame-injection case still
goes RED under strip_fence because the frame still routes through
_delimit.
version_hash is defined over the upstream canonical serialization. If
the agent re-serialized the bundle (model_dump_json on a re-parsed object), the
key order would shift and both the cache prefix and the hash-derived delimiter would break. So
ContextBundle is a thin envelope — the broker hands us the already-canonical
text and its version_hash, and we insert the text
verbatim. The full retrieval/bundle DTOs stay functions-side (they are not
redefined here — same custody rule as Blake's vendored DocumentCard).
Because a fresh nonce is minted on every build, the byte-identity test is self-enforcing: if any nonce leaked into the prefix, two builds of the same session's prefix would differ. Byte-equality is therefore proof the nonce left the prefix entirely.
p1 = build_converse_prompt(turn1) # same frame + bundle + meta p2 = build_converse_prompt(turn2) # + one more history turn, new counsel message assert _stable_prefix(p1) == _stable_prefix(p2) # THE GATE — cache prefix is byte-stable assert p1[-1] != p2[-1] # the suffix genuinely differs assert "COUNSEL_MESSAGE" not in _stable_prefix(p1) # the nonce block rides the suffix only
The bundle field is accepted from the envelope now, but the converse route is
frame-only until OQ-4 (enablement gated on the S-10 bake-off showing a
≥70% hit rate). So bundle=None is the default and fully supported: no
CONTEXT_BUNDLE block, one breakpoint (A, after the frame). When the broker starts
populating the bundle, breakpoint B lights up with no further agent-side change.
The session-memory block (slot 3 in the full RFD layout) is S-6, and delta-significance pinning is S-7 — both depend on S-3 and are out of scope here. S-3 lays the sandwich they slot into.
uv run --package mira pytest agents/mira/tests → 120 passed (converse +
adversarial + provider + models). uv run pytest tests/ → 175 passed
(governance, incl. the S-2 provider-parity byte-compare). ruff check clean.