Mira gets her voice

Story-A gave Mira a body — the runtime shell and the frozen contracts. This Story (Story-B) wires the first of her two doors: converse — fast, matter-scoped, streaming chat. Counsel types a question about one matter; Mira answers in markdown, token by token, grounded in the current frame, and changes nothing. This replaces Atlas's matter-facing chat.
Story-2026 (Story-B) · EPIC-2021 (Mira) · depends on Story-2022 (skeleton, merged) · repo pramaan-agents · pramaan-docs ADR-110 (event stream / thin brain) · ADR-117 (Pydantic AI) · ADR-151/116/120 (config-driven OpenRouter, ZDR) · design docs/notes/rfd-mira-matter-agent.md §2.1 / §5.2 / §9 Story-B

Two routes share one AgentCore unit, selected by intent. This PR fills in intent:"converse" behind the door Story-A froze; the command route stays scaffolded (Story-C). The change is deliberately additive: the dispatch grew one branch, and a new engine file (converse.py) carries all the new behaviour — a sibling Mira Story touches the same runtime in parallel, so nothing existing was rewritten.

1 · The one thing that matters — it only talks

Conversation is read-only. The route reasons over the frame and streams markdown; it never proposes a frame, never runs the panel, never writes anything. That is not a comment — it is the shape of the output. The turn is Mira's MiraConverseTurn: markdown plus an (unused-in-v1) suggested_command slot. There is no frame, no changed_fields, no requires_counsel_approval on any converse event — a test asserts none of those keys can appear.

Why the "do" work lives in a different route entirely: rewriting the legal Brief earns Vega's full proposer→critic→synthesizer panel — its diversity floor, its critic-as-evidence-accountant, its deterministic lock carry-through. Chat does not. Fusing them would either make chat slow and expensive, or let a cheap single-pass model quietly edit the record. So talk and do are separated by construction: converse is one fast model with a str output that cannot emit a frame; command (Story-C) is the panel with a FrameOutput output that always requires counsel approval.

2 · The event sequence

runtime.stream_invoke            # Bug-1370 boundary watchdog (brain on a private thread)
  └► handle_request              # parse BEFORE any event (fail-closed)
      └─ intent="converse" ─► converse.stream_converse(run_id, MiraConverseInput, model_id)
           run.started            # stamps converse model + prompt_version + mira_converse.v1
           turn.delta  ×N         # streamed markdown — real run_stream().stream_text(delta=True)
           model.usage            # real token totals (feeds the broker cost cap)
           turn.completed         # {turn:{markdown, suggested_command:null}}
           run.completed          # stop_reason:"end_turn"
  # OR, on a stall/provider error: run.started → run.failed{failure_stage:"converse", ...}

3 · Streaming for real — and why Bug-1370 can't bite here

The output type is plain str (markdown), streamed via agent.run_stream(...).stream_text(delta=True). Bug-1370 — a wedged OpenRouter stream freezing the AgentCore worker loop — is specific to the flat-DTO PromptedOutput / stream_output path. Plain-text streaming is a different code path, and it is already proven in this repo (agents/atlas/live_feed.py streams text over OpenRouter ZDR on the same SDK). Full reasoning: docs/notes/mira-converse-streaming-decision.md.

Why the fallback the spike allowed is not implemented: two guards make a worker-loop freeze structurally unreachable. (1) The shape isn't the Bug-1370 shape. (2) The brain runs on a private event loop in a private thread (the runtime watchdog) — a stalled stream can only trip that watchdog, never freeze the worker loop. A chunked-run() fallback would be dead code; if a specific model ever hangs its text stream, the per-attempt deadline already turns that into a typed error turn, and the fallback becomes a localized change.

4 · Two deadlines, both fail typed — never a spinner

DeadlineWrapsOn trip
MIRA_CONVERSE_ATTEMPT_TIMEOUT_S (20s)only the model call (asyncio.wait_for(_drive())) — consumer time not countedrun.failed · failure_kind:"timeout"
MIRA_INVOKE_BUDGET_S (780s)the whole invoke, on the worker loop (the Bug-1370 watchdog)run.failed · failure_kind:"invoke_budget_exceeded"

Counsel is typing live, so first-token latency is what matters; 20s is the anti-hang backstop, not the streaming budget. Any provider error is caught the same way — a typed failure_kind:"model_error" turn (Atlas's error taxonomy), never a raised exception that reads as a frozen spinner.

5 · Grounding + injection defence

The prompt is small on purpose (RFD §2.1): light matter metadata + the current frame (as JSON) + recent history + the counsel message. No documents body — the frame is the compressed matter context, which keeps the turn fast. Every untrusted piece rides inside a per-run nonce delimiter (<<MATTER_FRAME_9f3a1c>>…), and the system prompt's input-channel rule treats everything inside <<…>> as data, never instructions — so an adversarial line in a frame can't make Mira leave this matter, invent a fact, or act on the world.

Why matter-scope is written into the prompt, not just assumed: Mira is the head of one matter (unlike Atlas's workspace-wide dock). The role prompt hard-lines it: no other matters, no cross-matter counts, no navigation, no actions. Combined with the read-only output shape, "stay in this matter" is enforced both in what she's told and in what she can physically emit.

6 · What proves it (offline, no network)

TestGuards
test_converse.py · full sequenceDrives stream_converse against a real Pydantic AI TestModel: asserts run.started → turn.delta×N → model.usage → turn.completed → run.completed, correct stamps, and that the streamed deltas reassemble to the final markdown.
test_converse.py · read-onlyThe turn carries only {markdown, suggested_command}; no frame/changed_fields/requires_counsel_approval/frame_status leaks on any event.
test_converse.py · timeout + errorA stalled agent trips the per-attempt deadline → typed timeout turn; a raising agent → typed model_error turn. Never an unhandled raise.
test_runtime.py · dispatchconverse hands the parsed input to stream_converse and relays its frames (no not_wired tail); command stays scaffolded; the watchdog still ships a typed terminal on a wedged brain.
# the whole run, green, offline
uv run --package mira pytest agents/mira/tests/ -q
34 passed

Acceptance: streams first token fast → terminates in turn.completed · no frame mutation · matter-scoped · typed error turn on timeout/provider error · streaming-vs-Bug-1370 decision recorded (docs/notes) · fast ZDR model rostered in llm.yaml · uv run --package mira pytest green.

Why no live-model test: per founder doctrine (2026-07-02), the eval harness — not a CI unit test — is acceptance for an agent brain, and a live OpenRouter call is neither deterministic nor offline. TestModel exercises the real SDK streaming path so the plumbing is proven; live latency/ZDR-availability is a deploy-time check (Logfire shows invoke_agent under pramaan-agents-mira once deployed).