Outcome: counsel's most consequential question — is this citation
real? — now reaches a source. Vani's six case-law tools stop answering
UNAVAILABLE: this capability is not built and become live deferred
declarations against the broker executor that merged tonight
(pramaan-functions #870, Story-3133). Nine stubs remain.
counsel pastes the other side's authorities → Vani calls citation_audit(citations=[…]) (no matter reference in the params) → SDK returns DeferredToolRequests.calls; the turn ENDS → broker loads THIS matter's protected terms under counsel's RLS → broker scrubs the outbound query, calls Indian Kanoon from a non-VPC boundary → broker re-invokes Vani with a bounded typed result → Vani answers: a row per citation, each with what was found and what was not
requests, no httpx, and no Python function behind these tools —
they are SDK ExternalToolset definitions, so the model's call is an intent and
nothing else. Scrubbing and the gateway call are the broker's and were already merged.
Ending the turn is also the performance answer (D9): a lookup takes 8–11 seconds, longer
than a whole turn.One tool is named verify_citation, and that name is model-facing: it invites
the seat to answer "I verified this." It cannot be renamed from this side — it is the
broker's registry key — so three things push against it instead:
NEVER SAY VERIFIED,
NEVER SAY FABRICATED);Recommendation for a later ticket: the honest name is lookup_citation. It
needs the broker's registry key to move first, so it is deliberately not done here.
Three outcomes are three different facts and the code keeps them apart:
| Status | What it means | What Vani may say |
|---|---|---|
FOUND_IN_SOURCE | the corpus holds matching records | report court, date and citation count off the bound record |
NOT_FOUND_IN_CORPUS | we searched; nothing matched there | "I could not locate this authority in Indian Kanoon" — never "fabricated" |
UNAVAILABLE | the lookup could not run at all | the returned reason, plainly — never "there is no problem" |
An empty or malformed payload is rejected at the brain boundary
(MiraToolResult), because [] reaching the prompt is what makes an
agent say "there are no problems with their citations" about a check nobody ran. That is
fabrication by omission in a legal record.
A validator alone was not enough in Story-3101: rejected text had already streamed to counsel before it was rejected. So a case-law turn joins the record turn in withholding answer deltas until validation passes, then emits the validated body. It costs that one turn its token-by-token stream — the turn that already waited 8–11 seconds on the source. Provider thinking deltas and the inter-chunk dead detector are unchanged.
proposition_match and citation_history take a record id that
exists only inside a previous lookup's result, and the prompt forbids inventing one. A
strictly single-leg rail would therefore declare two tools live that nothing could reach
honestly. The follow-up leg offers only the six case-law tools — no
resolver, no frame delegate, no stubs — and the broker's own
_MAX_TOOL_STEPS bounds the chain.
The exact OpenAI-wire tool catalog is counted with o200k_base. Six
zero-parameter stub schemas became six live tools carrying real parameter schemas and the
output-vocabulary rules, so the number moved:
| Measurement | Before | After |
|---|---|---|
| Full catalog over the resolver-only baseline | 1,281 tokens / 6,086 bytes | 2,145 tokens / 9,903 bytes |
| Assembled instructions + user prompt (control bytes) | 14,980 bytes | 16,980 bytes |
Both are asserted in tests and fail on drift. The prompt version is bumped to
mira.converse.v13: a prompt edit is a cache event fleet-wide, which is exactly
what a real parameter surface costs.
| Requirement | Proof |
|---|---|
| The six are live deferred declarations, not stubs | test_the_six_case_law_tools_are_live_deferred_declarations |
| Params match the broker's strict models exactly | test_case_law_param_keys_match_the_broker_models_exactly |
| Each call becomes one broker intent, with no matter reference | test_each_case_law_call_becomes_one_broker_intent |
| A stray key cannot fail a 20-citation audit at the boundary | test_unknown_keys_are_dropped_before_they_can_fail_the_whole_call |
| An unrunnable call never buys a lookup | test_an_unrunnable_case_law_call_never_reaches_the_broker |
| Real data reaches the prompt through a whitelist | test_each_case_law_tool_exposes_real_data_through_its_whitelisted_view |
| A miss is explained; a failure is UNAVAILABLE; neither is empty | test_a_case_law_miss_is_explained_and_a_failure_is_unavailable_never_empty,
test_a_corpus_miss_is_not_the_unavailable_contract |
| No answer claims verification, and the rejected one never streams | test_a_verification_claim_is_rejected_and_never_reaches_the_delta_stream |
| An UNAVAILABLE case-law result must be disclosed | test_a_case_law_unavailable_result_must_be_disclosed_plainly |
| The follow-up leg offers only the six | test_a_case_law_result_reopens_only_the_case_law_tools,
test_a_follow_up_lookup_rides_the_same_deferred_rail |
| The other nine stubs still return UNAVAILABLE | test_every_stub_returns_unavailable_and_vani_says_it_is_not_built |
| Story-3101 delta guard remains | test_rejected_unavailable_finding_never_reaches_the_delta_stream |
| Re-measured prefix cost is asserted | test_converse_registers_the_exact_specced_tools_and_measured_schema_delta |
uv run ruff check . → All checks passed uv run pytest -q → 457 passed (repo governance) cd agents/mira && pytest tests → 364 passed (the CI-scoped agent step)