Story-2080 Walkthrough

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.

The one problem being fixed

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").

What ships

FileRole
agents/mira/converse.pyThe 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.pyContextBundle (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.pyThe 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.

The sandwich (RFD §5.1 D-1)

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

Two delimiter disciplines (RFD §5.2 D-2)

The nonce did two jobs at once: injection defence and block delimiting. S-3 splits them by what the block is:

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.

Ship the bundle verbatim (RFD §5.2)

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).

The gate — byte-identical prefix, proven by the nonce itself

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

OQ-4: wiring ships now, enablement follows measurement

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.

Scope boundary

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.

Verification

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.