Story-3016

Mira's converse history is bounded — trim, floor, budget

Until this change the broker handed Mira every message a matter conversation had ever produced. That tail sits after the prompt's CachePoint, so it is never cached: the cost of a turn grew linearly with the length of the session, and — the reason that actually matters — forty old turns diluted the model's attention on the question counsel just asked. This story bounds the history broker-side, in _converse_history (src/clerk_worker/runner.py), which is where it belongs: the brain receives a finished array and does no windowing of its own.

The rule, in order

1. TRIM   each HISTORY message to HISTORY_MESSAGE_MAX_TOKENS (1000)
          head 400 tokens + "… N tokens omitted …" + tail 400 tokens
2. NEVER  trim the CURRENT counsel message (it is not part of history)
3. FLOOR  always keep the newest HISTORY_WINDOW_FLOOR_MESSAGES (10)
4. BUDGET extend backwards in whole exchange pairs, up to
          HISTORY_WINDOW_MAX_MESSAGES (25), while the estimated total
          stays within HISTORY_WINDOW_MAX_TOKENS (4000)

Why each step is shaped the way it is

Step Why
Head and tail The part counsel is asking about sits at one end or the other: a pasted clause is quoted from the top, a long answer is referred to by its conclusion.
A visible omission marker Load-bearing, not cosmetic. Silent truncation hands the model a sentence that simply stops, and a model reads a stopped sentence as a finished one — so it confabulates the ending. On this rail the endings are legal text: a clause cut mid-sentence and silently completed is a fabricated term, spoken in Mira's voice. The count is the real estimated omission, so counsel can decide whether to re-paste.
The current message is never trimmed Trimming the live question means answering about text counsel was not shown. It rides command['message'] and never enters the history array, so it is structurally out of the trim's reach — and a test over the real command path fails if a refactor ever folds it in.
A floor that beats the budget Without it, one pasted contract eats the whole token budget and Mira sees a blob and no conversation. Ten messages survive even when they alone exceed 4000 tokens.
Whole exchange pairs An answer whose question was dropped reads as an unprompted assertion, and the model then reasons over a claim with no visible ask behind it. The open edge of the window only ever moves outward, never inward, to land on a question.

The cross-field validator — the point of the ticket

4000 is 2× memory_fold_min_tokens (2000), the compaction trigger, and that is not a coincidence to be quietly broken by a later env change. The fold and this window are two halves of one memory: the fold holds everything older, the window holds what the fold has not absorbed yet. If the window were smaller than the trigger, a stretch of conversation would be held by neither — aged out of the window, not yet large enough to have been folded — and counsel's reference to it would go unanswered with no signal anywhere. The 2× (not 1×) covers lag: _maybe_enqueue_compaction enqueues over SQS, so turns keep arriving while the compactor runs.

@model_validator(mode="after")
def _window_covers_the_fold_gap(self) -> Settings:
    if self.history_window_max_tokens < 2 * self.memory_fold_min_tokens:
        raise ValueError(
            f"history_window_max_tokens ({self.history_window_max_tokens}) must be >= "
            f"2x memory_fold_min_tokens ({self.memory_fold_min_tokens}) — the fold is "
            "async; below 2x a reference falls into a gap held by neither."
        )
    return self

So MEMORY_FOLD_MIN_TOKENS=4000 with the window left at 4000 fails service start, loudly, with the reason attached — instead of silently dropping context in production.

Config vs constant

Number Where it lives
4000 / 25 / 10 / 1000 Settings, env-overridable: HISTORY_WINDOW_MAX_TOKENS, HISTORY_WINDOW_MAX_MESSAGES, HISTORY_WINDOW_FLOOR_MESSAGES, HISTORY_MESSAGE_MAX_TOKENS — placed beside memory_fold_min_tokens so the coupling is visible at the point of edit.
head 400 / tail 400 Constants. They are the shape of a trim, not a per-deploy knob; the knob is HISTORY_MESSAGE_MAX_TOKENS, which decides whether a message is trimmed at all. They are capped at half the configured per-message budget so lowering that setting still bounds the result.
token estimate The same char/4 heuristic the fold estimate uses — a budget signal, not billing. It undercounts rather than over, which is the safe direction.

Observability

When the window drops anything, the broker logs clerk.converse_history_window_bounded with dropped, kept, and the estimated token total, so a session that is losing context shows up as a signal rather than silence — the same posture as the existing clerk.converse_history_entries_dropped warning.

Surface note. This is a broker-internal change with no new route, no schema change, and no user-visible control; the visible effect is a shorter, cheaper prompt and steadier answers on long sessions. The tests that pin it live in tests/unit/clerk_worker/test_converse_history_window.py and test_runner.py::test_the_current_counsel_message_is_never_trimmed.