fix(ai-chat): реактивная рекавери — итеративная эскалация 0.5^k + пол вместо фикс. 0.5× (#520) #546

Open
agent_coder wants to merge 2 commits from fix/520-recovery-escalation into develop
Collaborator

Summary

Follow-up к #490/#510: реактивная рекавери после context-overflow 400 была фиксированной долей 0.5× от булева replayOverflow — не эскалировала. При НЕзаданном chatContextWindow (плоский дефолт 100k) и реально малом окне модели (<50k) даже 0.5×100k=50k переполняло снова, ход перештамповывался, но булев был уже true → второго ужесточения нет. Остаточный кирпич: чат застревал на 50k каждый ход. closes #520.

Фикс (итеративная эскалация + пол)

  • Сигнал boolean replayOverflow → счётчик последовательных overflow metadata.replayOverflowCount (k). Инкремент: priorOverflowCount + 1 (стрик читается в начале хода). Сброс: чистый finalize опускает поле → читатель берёт самый свежий assistant-row → отсутствие = k=0 (авто-сброс). Back-compat: legacy replayOverflow:true без счётчика → k=1 (старое 0.5× для in-flight чатов); отрицательное/NaN → 0.
  • resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold · 0.5^k), min(REPLAY_MIN_FLOOR_TOKENS, threshold)). k=1→0.5×, k=2→0.25×, … сходится/клэмпится на REPLAY_MIN_FLOOR_TOKENS = 8_000. min(floor, threshold) — чтобы пол НЕ поднимал легитимно малый сконфигурированный бюджет выше себя (иначе переполнил бы своё же окно). null/k=0 → passthrough (off-switch не переопределяется).

How verified (реальный pg)

  • history-budget 19; ai-chat.service+run-race 137; write-volume int-spec (jsonb lifecycle 1→2→3, reset→0, legacy→1) 3 — все зелёные.
  • Регресс-тест сходимости: base=100k, realWindow=40k → утверждает СХОДИМОСТЬ ([2,3,4,5].some(k → threshold<40k)), а не «≠50k». Мутация **k → **1 краснит 5 тестов (un-bricks + floor-clamp).
  • #490-семантика не ослаблена (overflow штампует count=1, non-overflow — ничего); isContextOverflowError/resolveReplayBudget/keep-recent-turns не тронуты.
  • Внутренний цикл: реализация → адверсариальный ревью (жизненный цикл счётчика + сходимость, мутация-подтверждена) → APPROVE. node_modules 0; scope apps/server/src/core/ai-chat/.

Осознанный трейд-офф (INFO из ревью)

Маленький сконфигурированный бюджет (threshold<8k) эскалацией ниже себя не тянется (по замыслу — пол не должен переполнять собственное окно). Остаточный кейс, не дефект.

Checklist

  • критерии приёмки из #520 выполнены
  • вне scope ничего не менялось; node_modules нет
## Summary Follow-up к #490/#510: реактивная рекавери после context-overflow 400 была фиксированной долей `0.5×` от булева `replayOverflow` — не эскалировала. При НЕзаданном `chatContextWindow` (плоский дефолт 100k) и реально малом окне модели (<50k) даже `0.5×100k=50k` переполняло снова, ход перештамповывался, но булев был уже `true` → второго ужесточения нет. Остаточный кирпич: чат застревал на 50k каждый ход. closes #520. ## Фикс (итеративная эскалация + пол) - Сигнал boolean `replayOverflow` → счётчик **последовательных** overflow `metadata.replayOverflowCount` (k). Инкремент: `priorOverflowCount + 1` (стрик читается в начале хода). Сброс: чистый finalize опускает поле → читатель берёт самый свежий assistant-row → отсутствие = k=0 (авто-сброс). Back-compat: legacy `replayOverflow:true` без счётчика → k=1 (старое 0.5× для in-flight чатов); отрицательное/NaN → 0. - `resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold · 0.5^k), min(REPLAY_MIN_FLOOR_TOKENS, threshold))`. k=1→0.5×, k=2→0.25×, … сходится/клэмпится на **`REPLAY_MIN_FLOOR_TOKENS = 8_000`**. `min(floor, threshold)` — чтобы пол НЕ поднимал легитимно малый *сконфигурированный* бюджет выше себя (иначе переполнил бы своё же окно). `null`/k=0 → passthrough (off-switch не переопределяется). ## How verified (реальный pg) - history-budget **19**; ai-chat.service+run-race **137**; write-volume int-spec (jsonb lifecycle 1→2→3, reset→0, legacy→1) **3** — все зелёные. - Регресс-тест сходимости: `base=100k, realWindow=40k` → утверждает СХОДИМОСТЬ (`[2,3,4,5].some(k → threshold<40k)`), а не «≠50k». Мутация `**k → **1` краснит 5 тестов (un-bricks + floor-clamp). - #490-семантика не ослаблена (overflow штампует count=1, non-overflow — ничего); `isContextOverflowError`/`resolveReplayBudget`/keep-recent-turns не тронуты. - Внутренний цикл: реализация → адверсариальный ревью (жизненный цикл счётчика + сходимость, мутация-подтверждена) → APPROVE. node_modules 0; scope `apps/server/src/core/ai-chat/`. ## Осознанный трейд-офф (INFO из ревью) Маленький *сконфигурированный* бюджет (threshold<8k) эскалацией ниже себя не тянется (по замыслу — пол не должен переполнять собственное окно). Остаточный кейс, не дефект. ## Checklist - [x] критерии приёмки из #520 выполнены - [x] вне scope ничего не менялось; node_modules нет
agent_coder added 2 commits 2026-07-12 05:09:59 +03:00
Остаточный кирпич (#490/#510): при незаданном chatContextWindow база = плоский
дефолт 100k, а реальное окно модели маленькое (<50k). Фиксированный одинарный
cut 0.5×100k=50k всё равно превышал реальное окно → провайдер снова 400,
строка переставлялась replayOverflow, но булев priorOverflowed уже был true →
второго ужатия не происходило. Чат навсегда застревал на 50k и не восстанавливался.

Фикс (без парсинга тел 400):
- Сигнал из булева переведён в счётчик `metadata.replayOverflowCount` = число
  ПОДРЯД идущих overflow-ходов: инкремент (prior+1) на каждом overflow, сброс в 0
  на любом чистом финализе (чистая строка не пишет поле → читается как 0).
  BACK-COMPAT: старая строка с булевым `replayOverflow:true` читается как k=1.
- resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold·0.5**k),
  min(REPLAY_MIN_FLOOR_TOKENS, threshold)). k=0 → база; k=1 → 0.5×; k=2 → 0.25×;
  большой k → упирается в пол 8k (сходимость). null-база (trimming OFF) не трогается;
  пол никогда не поднимает легитимно малый настроенный бюджет выше него самого.
- Пол REPLAY_MIN_FLOOR_TOKENS=8k: ниже него чат не несёт осмысленный недавний
  контекст, и даже малое реальное окно его вмещает; keep-recent-turns сверху.

Тесты: таблица эскалации (k=0/1/2/большой/null), регрессия остаточного кирпича
(база 100k, окно ~40k → сходится ниже 40k, чего фиксированный 0.5× никогда не мог),
жизненный цикл счётчика на реальном pg (инкремент/сброс/back-compat через jsonb),
мутация **k→**1 краснит convergence-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-12 05:09:59 +03:00
Collaborator

Ревью — эскалация рекавери ai-chat (#520). Вердикт: needs-human

Весь PR корректен и хорошо покрыт (крюкс-счётчик, математика, back-compat, обычный чат — всё сверено + гейт зелёный: unit 156/156, int 3/3 на реальном pg, 3 мутации убиты). Открыт ОДИН вопрос — продуктовая развилка, которую я не должен решать сам: как рекавери ведёт себя при МАЛОМ настроенном окне модели. По умолчанию (окно не настроено) всё идеально; вопрос только про явно сконфигурированные окна < ~11 400 токенов.

Контекст (простыми словами)

Когда ai-chat отправляет модели слишком длинную историю, модель отвечает ошибкой «context overflow». Этот PR чинит баг #520: раньше рекавери один раз обрезала историю до 0.5× бюджета и, если и это не влезало, чат ЗАСТРЕВАЛ навсегда («кирпич»). Теперь при каждом ПОДРЯД overflow'е обрезка усиливается: бюджет × 0.5^k (k = число overflow'ов подряд), с нижним полом. Для дефолтного (ненастроенного) окна 100k всё работает: 100k→50k→25k→…→8k, пока не влезет, потом чистый ход сбрасывает k. Это правильно.

Главный вопрос

Нижний пол задан как min(8000, настроенный_бюджет). Для НАСТРОЕННОГО окна модели меньше ~11 400 токенов (бюджет = 0.7×окно < 8000) пол становится равен самому бюджету → рекавери вообще перестаёт резать, при любом k. Два следствия:

  1. Регрессия vs то, что сейчас в develop: старый код на overflow ВСЕГДА резал до 0.5× (например бюджет 5600 → 2800). Новый оставляет 5600 (не режет). Т.е. для малых окон этот PR — «фикс кирпича» — делает рекавери СЛАБЕЕ, чем было.
  2. Возвращает ровно тот кирпич #520 для малых моделей: админ ставит chatContextWindow=8000 (реальная 8k-модель) → бюджет 5600; если 5600 токенов + системный промпт + схемы инструментов не влезают в 8k, старый код резал до 2800 и восстанавливался, новый застревает на 5600 навсегда.

Автор ОСОЗНАННО закодил и покрыл тестом «никогда не резать ниже настроенного бюджета» (history-budget.spec.ts:67 «never inflates a small configured budget above itself»). Поэтому это не явный баг-с-очевидным-фиксом, а решение о контракте: настроенный админом бюджет — это ЖЁСТКИЙ пол или нет? Я не могу решить за продукт (нужно знать, поддерживает ли gitmost малоконтекстные модели).

Варианты
Суть Плюсы Минусы
A (как сейчас) Настроенный бюджет — жёсткий пол, ниже не резать Уважает конфиг админа; просто Регрессия vs develop; НАВСЕГДА бриктует чаты на моделях с реальным окном ≤ ~11k токенов
B Разрешить рекавери резать НИЖЕ настроенного бюджета до малого абсолютного минимума (напр. 2k) при повторных overflow Восстанавливает un-brick для малых окон — собственно цель #520 Рекавери может обрезать ниже, чем настроил админ

Рекомендация: как минимум НЕ регрессировать — сделать пол настоящим АБСОЛЮТНЫМ минимумом, а не «= настроенному бюджету» (напр. Math.max(scaled, Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(threshold*0.5)))), чтобы для базы<8000 сохранился хотя бы старый 0.5×-рез (спека «не РАЗДУВАТЬ малый бюджет» при этом остаётся выполнена — раздувание и так не происходит). Это ≈ Вариант B с полом ~половина-бюджета. Выбирай A, только если окна < ~11.4k явно вне scope #520 — тогда стоит задокументировать, что chatContextWindow должен быть ≥ ~11 400.

Объективка зелёная, весь остальной PR верен — открыт только этот выбор. Loop остановлен до твоего решения.

## Ревью — эскалация рекавери ai-chat (#520). Вердикт: **needs-human** Весь PR **корректен и хорошо покрыт** (крюкс-счётчик, математика, back-compat, обычный чат — всё сверено + гейт зелёный: unit 156/156, int 3/3 на реальном pg, 3 мутации убиты). Открыт ОДИН вопрос — продуктовая развилка, которую я не должен решать сам: как рекавери ведёт себя при МАЛОМ настроенном окне модели. По умолчанию (окно не настроено) всё идеально; вопрос только про явно сконфигурированные окна < ~11 400 токенов. ### Контекст (простыми словами) Когда ai-chat отправляет модели слишком длинную историю, модель отвечает ошибкой «context overflow». Этот PR чинит баг #520: раньше рекавери один раз обрезала историю до 0.5× бюджета и, если и это не влезало, чат ЗАСТРЕВАЛ навсегда («кирпич»). Теперь при каждом ПОДРЯД overflow'е обрезка усиливается: бюджет × 0.5^k (k = число overflow'ов подряд), с нижним полом. Для дефолтного (ненастроенного) окна 100k всё работает: 100k→50k→25k→…→8k, пока не влезет, потом чистый ход сбрасывает k. Это правильно. ### Главный вопрос Нижний пол задан как `min(8000, настроенный_бюджет)`. Для НАСТРОЕННОГО окна модели меньше ~11 400 токенов (бюджет = 0.7×окно < 8000) пол становится равен самому бюджету → рекавери **вообще перестаёт резать**, при любом k. Два следствия: 1. **Регрессия vs то, что сейчас в develop:** старый код на overflow ВСЕГДА резал до 0.5× (например бюджет 5600 → 2800). Новый оставляет 5600 (не режет). Т.е. для малых окон этот PR — «фикс кирпича» — делает рекавери СЛАБЕЕ, чем было. 2. **Возвращает ровно тот кирпич #520** для малых моделей: админ ставит `chatContextWindow=8000` (реальная 8k-модель) → бюджет 5600; если 5600 токенов + системный промпт + схемы инструментов не влезают в 8k, старый код резал до 2800 и восстанавливался, новый застревает на 5600 навсегда. Автор ОСОЗНАННО закодил и покрыл тестом «никогда не резать ниже настроенного бюджета» (`history-budget.spec.ts:67` «never inflates a small configured budget above itself»). Поэтому это не явный баг-с-очевидным-фиксом, а решение о контракте: настроенный админом бюджет — это ЖЁСТКИЙ пол или нет? Я не могу решить за продукт (нужно знать, поддерживает ли gitmost малоконтекстные модели). <details> <summary>Варианты</summary> | | Суть | Плюсы | Минусы | |---|---|---|---| | **A (как сейчас)** | Настроенный бюджет — жёсткий пол, ниже не резать | Уважает конфиг админа; просто | Регрессия vs develop; НАВСЕГДА бриктует чаты на моделях с реальным окном ≤ ~11k токенов | | **B** | Разрешить рекавери резать НИЖЕ настроенного бюджета до малого абсолютного минимума (напр. 2k) при повторных overflow | Восстанавливает un-brick для малых окон — собственно цель #520 | Рекавери может обрезать ниже, чем настроил админ | **Рекомендация:** как минимум НЕ регрессировать — сделать пол настоящим АБСОЛЮТНЫМ минимумом, а не «= настроенному бюджету» (напр. `Math.max(scaled, Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(threshold*0.5)))`), чтобы для базы<8000 сохранился хотя бы старый 0.5×-рез (спека «не РАЗДУВАТЬ малый бюджет» при этом остаётся выполнена — раздувание и так не происходит). Это ≈ Вариант B с полом ~половина-бюджета. Выбирай A, только если окна < ~11.4k явно вне scope #520 — тогда стоит задокументировать, что `chatContextWindow` должен быть ≥ ~11 400. </details> Объективка зелёная, весь остальной PR верен — открыт только этот выбор. Loop остановлен до твоего решения.
agent_reviewer added needs-human and removed review/needs labels 2026-07-12 07:05:04 +03:00
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/520-recovery-escalation:fix/520-recovery-escalation
git checkout fix/520-recovery-escalation
Sign in to join this conversation.