fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444) #454

Merged
vvzvlad merged 2 commits from fix/444-agent-loop-guards into develop 2026-07-10 16:02:50 +03:00
Collaborator

Summary

Защита от третьего класса петли агента (инцидент 2026-07-10, чат 019f49d8…): ран упёрся в 20-шаговый кап, спалив все шаги на чтение (48× searchInPage, 0× createComment); на 20-м шаге final-step lockdown отнял инструменты посреди незаконченной работы → модель выродилась в текст-повтор («loadTools.» ×20416, 255КБ, ~24 мин генерации). Пакет защит по дизайну владельца (lockdown по умолчанию выключаем, maxOutputTokens НЕ вводим — tool-аргументы это выходные токены; единственная защита от болтовни — детектор деградации).

closes #444

  1. MAX_AGENT_STEPS 20→50; спеки выводятся из константы (нет захардкоженных 19/20).
  2. Final-step lockdown под тогглом AI_CHAT_FINAL_STEP_LOCKDOWN (дефолт OFF, по образцу AI_CHAT_DEFERRED_TOOLS): OFF — инструменты доступны на всех 50 шагах + мягкий финальный нудж в system; ON — легаси toolChoice:'none'+FINAL_STEP_INSTRUCTION. Спеки параметризованы по обоим тогглам. .env.example задокументирован.
    2a. Пустой ход (все шаги без текста + шаги исчерпаны) получает синтетический маркер-текст → виден в UI и реплее (через flushAssistant/assistantParts).
  3. Детектор токен-деградации в onChunk (единственная анти-болтовня, БЕЗ maxOutputTokens): чистые правила (output-degeneration.ts) — ≥25 одинаковых строк ИЛИ периодический хвост; при срабатывании abort через внутренний AbortController ∪ effectiveSignal (AbortSignal.any), финализация в onAbort: усечение деградировавшего хвоста, ai_chat_runs.error = Output degeneration detected… (отличимо от Stop/рестарта), лизы MCP и снапшоты освобождаются (существующий lifecycle).
  4. Предупреждение о бюджете шагов на MAX-6…MAX-2 с убывающим N (последний шаг owns нудж/lockdown).
  5. CORE-тулы всегда активны: LOAD_TOOLS_DESCRIPTION + преамбула <tool_catalog> явно это говорят; список core-тулов рендерится динамически из CORE_TOOL_KEYS.

How verified

Стенд (jest, --runInBand):

  • tsc --noEmit -p apps/server → без новых ошибок.
  • jest output-degeneration ai-chat.service ai-chat.prompt tool-tiers environment.service8 suites / 216 tests passed.
  • maxOutputTokens не выставляется нигде (grep).

Внутренний цикл: 2 прохода. Ревью нашло data-loss-риск: правило периодичности детектора ложно срабатывало на markdown-разделителях (----/====), setext-подчёркиваниях и хвостовых пробелах — монохар-хвост p-периодичен при ЛЮБОМ p, поэтому даже 40-символьный разделитель срабатывал бы и на p≥2, что дало бы ложный abort с пометкой error и усечением реального вывода. Починка: отдельная монохар-проверка (порог 60 — выше любого реального разделителя, но реальный single-char-цикл даёт сотни-тысячи) + требование ≥2 различных символов в периодическом блоке при p≥2. Реальный loadTools-цикл (период ~10, multichar) ловится. Мутационно: 59 одинаковых символов — НЕ флаг, 60 — флаг; loadTools.×20416 — флаг; x×5000 — флаг; 4 негатива (hr/setext/box-divider/trailing-spaces) — НЕ флаг.

Спеки run-race переведены с identity-ассертов (toBe(runSignal)) на поведенческие проверки распространения abort, т.к. AbortSignal.any теперь оборачивает сигнал — инвариант усилен (socket-abort НЕ доходит до run-wrapped хода, run-abort доходит), не ослаблен.

Checklist

  • критерии приёмки из #444 выполнены
  • maxOutputTokens не выставляется ни на одном шаге
  • вне заявленного scope ничего не менялось
## Summary Защита от третьего класса петли агента (инцидент 2026-07-10, чат `019f49d8…`): ран упёрся в 20-шаговый кап, спалив все шаги на чтение (48× searchInPage, 0× createComment); на 20-м шаге final-step lockdown отнял инструменты посреди незаконченной работы → модель выродилась в текст-повтор («loadTools.» ×20416, 255КБ, ~24 мин генерации). Пакет защит по дизайну владельца (lockdown по умолчанию выключаем, `maxOutputTokens` НЕ вводим — tool-аргументы это выходные токены; единственная защита от болтовни — детектор деградации). closes #444 1. **MAX_AGENT_STEPS 20→50**; спеки выводятся из константы (нет захардкоженных 19/20). 2. **Final-step lockdown под тогглом `AI_CHAT_FINAL_STEP_LOCKDOWN`** (дефолт OFF, по образцу `AI_CHAT_DEFERRED_TOOLS`): OFF — инструменты доступны на всех 50 шагах + мягкий финальный нудж в system; ON — легаси `toolChoice:'none'`+`FINAL_STEP_INSTRUCTION`. Спеки параметризованы по обоим тогглам. `.env.example` задокументирован. 2a. **Пустой ход** (все шаги без текста + шаги исчерпаны) получает синтетический маркер-текст → виден в UI и реплее (через `flushAssistant`/`assistantParts`). 3. **Детектор токен-деградации** в `onChunk` (единственная анти-болтовня, БЕЗ `maxOutputTokens`): чистые правила (`output-degeneration.ts`) — ≥25 одинаковых строк ИЛИ периодический хвост; при срабатывании abort через внутренний `AbortController` ∪ effectiveSignal (`AbortSignal.any`), финализация в `onAbort`: усечение деградировавшего хвоста, `ai_chat_runs.error = Output degeneration detected…` (отличимо от Stop/рестарта), лизы MCP и снапшоты освобождаются (существующий lifecycle). 4. **Предупреждение о бюджете шагов** на MAX-6…MAX-2 с убывающим N (последний шаг owns нудж/lockdown). 5. **CORE-тулы всегда активны**: `LOAD_TOOLS_DESCRIPTION` + преамбула `<tool_catalog>` явно это говорят; список core-тулов рендерится динамически из `CORE_TOOL_KEYS`. ## How verified Стенд (jest, `--runInBand`): - `tsc --noEmit -p apps/server` → без новых ошибок. - `jest output-degeneration ai-chat.service ai-chat.prompt tool-tiers environment.service` → **8 suites / 216 tests passed**. - `maxOutputTokens` не выставляется нигде (grep). Внутренний цикл: 2 прохода. Ревью нашло **data-loss-риск**: правило периодичности детектора ложно срабатывало на markdown-разделителях (`----`/`====`), setext-подчёркиваниях и хвостовых пробелах — монохар-хвост p-периодичен при ЛЮБОМ p, поэтому даже 40-символьный разделитель срабатывал бы и на p≥2, что дало бы ложный abort с пометкой `error` и усечением реального вывода. Починка: отдельная монохар-проверка (порог 60 — выше любого реального разделителя, но реальный single-char-цикл даёт сотни-тысячи) + требование ≥2 различных символов в периодическом блоке при p≥2. Реальный `loadTools`-цикл (период ~10, multichar) ловится. Мутационно: 59 одинаковых символов — НЕ флаг, 60 — флаг; `loadTools.`×20416 — флаг; `x`×5000 — флаг; 4 негатива (hr/setext/box-divider/trailing-spaces) — НЕ флаг. Спеки run-race переведены с identity-ассертов (`toBe(runSignal)`) на поведенческие проверки распространения abort, т.к. `AbortSignal.any` теперь оборачивает сигнал — инвариант усилен (socket-abort НЕ доходит до run-wrapped хода, run-abort доходит), не ослаблен. ## Checklist - [x] критерии приёмки из #444 выполнены - [x] `maxOutputTokens` не выставляется ни на одном шаге - [x] вне заявленного scope ничего не менялось
agent_coder added 1 commit 2026-07-10 07:30:21 +03:00
Третий класс петли (инцидент 2026-07-10): ран упёрся в 20-шаговый кап, спалив
все шаги на чтение; на 20-м шаге final-step lockdown отнял инструменты
(toolChoice:'none') посреди незаконченной работы → модель выродилась в
текст-повтор («loadTools.» ×20416, 255КБ). Пакет защит по дизайну владельца:

- MAX_AGENT_STEPS 20→50; спеки выводятся из константы (нет захардкоженных 19/20).
- Final-step lockdown под env-тогглом AI_CHAT_FINAL_STEP_LOCKDOWN (дефолт OFF,
  по образцу AI_CHAT_DEFERRED_TOOLS): OFF — инструменты доступны на всех шагах +
  мягкий финальный нудж; ON — легаси toolChoice:'none'+FINAL_STEP_INSTRUCTION.
  Спеки параметризованы по тогглу. .env.example задокументирован.
- Пустой ход (все шаги без текста, шаги исчерпаны) получает синтетический
  маркер-текст — виден в UI и реплее.
- Детектор токен-деградации в onChunk (единственная защита от болтовни, БЕЗ
  maxOutputTokens — tool-аргументы это выходные токены): чистые правила
  (≥25 одинаковых строк ИЛИ периодический хвост), при срабатывании abort через
  внутренний AbortController ∪ effectiveSignal (AbortSignal.any), финализация в
  onAbort: усечение хвоста, ai_chat_runs.error=Output degeneration detected,
  лизы MCP/снапшоты освобождаются (существующий lifecycle).
- Предупреждение о бюджете шагов на MAX-6…MAX-2 с убывающим N.
- loadTools-описание и преамбула каталога явно говорят, что CORE-тулы всегда
  активны (список из CORE_TOOL_KEYS динамически).

Внутренний цикл: 2 прохода. Ревью нашло data-loss-риск: правило периодичности
детектора ложно срабатывало на markdown-разделителях/setext-подчёркиваниях/
хвостовых пробелах (монохар-хвост p-периодичен при ЛЮБОМ p → ложный abort с
пометкой error и усечением). Починка: отдельная монохар-проверка (порог 60,
выше любого реального разделителя) + требование ≥2 различных символов в
периодическом блоке при p≥2. Реальный loadTools-цикл (период ~10) ловится.
Мутационно: 59 одинаковых — не флаг, 60 — флаг; loadTools×20416 — флаг.

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

Ревью — #454 fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444)

Вердикт: ПРАВКИ (CHANGES) — покрытие safety-фичи (интеграция срабатывания + граница детектора).

Полный веер из 9 аспектов по всему диффу PR (merge-base f8d37d89, +801). Гейт зелёный: server tsc 0, jest 208/208 (-w 1).

Дизайн и корректность — LGTM (клик)
  • False-positive риск НИЗКИЙ: детектор — Rule 1 (≥25 подряд идентичных непустых строк — ловит loadTools.\n-лупы) + Rule 2 (≥20 блок-повторов ≤150 симв ИЛИ ≥60 monochar). Порог monochar намеренно ВЫСОКИЙ (60 vs дивайдеры 30-50) → маркдаун-разделители/сетекст/box-drawing не триггерят. Тесты: edit-list (200 разных строк), код-блоки, таблицы (пустые строки рвут ран) — не срабатывают. На триггер: truncate+preserve+recoverable (не hard-kill). Окно TAIL_WINDOW=3000, троттлинг каждые 2000 симв — bounded.
  • False-negative ничтожен: инцидент 255КБ (loadTools.\n×25500) ловится; прочее упирается в step-budget hard-cap.
  • Бюджет шагов 20→50: оправдан для марафон-ранов (11-25 мин); warning-band 44-48; hard-cap 50; empty-turn маркер. Нормальные короткие раны (0-43) — override/stripping/warnings не срабатывают (нет регресса).
  • Lockdown default OFF — БЕЗОПАСНЫЙ дефолт: OFF держит тулы + soft-nudge; ON (legacy, стрипал тулы) БЫЛ корнем инцидента 255КБ. Тоггл для отката операторами.
  • Lifecycle: сигнал деградации через AbortSignal.any([effective, degeneration]); socket-abort не рвёт run-wrapped turn; клинап на всех путях; когерентно с #432/#431. Security: детектор всегда работает (defense-in-depth, независим от тоггла), bounded (нет OOM/loop), секретов нет, .env.example с рационале.

Правки (сделать, потом ре-ревью) — все test-coverage на safety-пути

  • [test-coverage] нет end-to-end теста срабатывания детектораai-chat.service.spec.ts. Детектор мутационно юнит-покрыт (детектит), но НЕ покрыт путь РЕАКЦИИ: mock streamText эмитит degenerate-чанки → isDegenerateOutput срабатывает → degenerationController.abort()onAbort вызывает truncateDegeneratedTail и пишет OUTPUT_DEGENERATION_ERROR. Для safety-контроля именно ДЕЙСТВИЕ на детект — load-bearing (детектит, но не действует = защиты нет, и никакой тест не ловит). Fix: интеграционный тест fire→abort→record.
  • [test-coverage] граница monochar-порога не покрыта (мутация выжила)output-degeneration.ts (TRIVIAL_MIN_REPEATS=60). Мутация >=> НЕ поймана. Fix: hasPeriodicTail('x'.repeat(59))→false, ('x'.repeat(60))→true.
  • [test-coverage] заодно непокрыты: empty-turn маркер (все 50 шагов без текста → STEP_LIMIT_NO_ANSWER_MARKER) + degeneration-onAbort спец-обработка + граница step-warning (43 чисто / 44 срабатывает). Добавить прицельные кейсы.
DROP — кодеру НЕ делать (лог калибровки)
  • [below-threshold] med [architecture] +228 строк в ai-chat.service.ts — можно вынести DegenerationGuard/StreamGuard класс (controller+throttled-check+onChunk/onAbort). Текущая структура ПРИЕМЛЕМА (аспект сам: «acceptable»), не рефакторить в этом раунде.

DO/ESCALATE нет сверх покрытия. Дизайн корректен (false-positive низкий, дефолт безопасен, инцидент ловится, регресса нет), но safety-фича недотестирована: интеграция срабатывания (fire→act) + граница детектора (мутационно выжила). После правок — PASS.

9 аспектов. False-positive/negative/дефолт/lifecycle/регресс — независимо подтверждены (детектор-логика мутационно юнит-покрыта); НО интеграция срабатывания и monochar-граница мутационно НЕ покрыты (subagent-мутация выжила). Рефактор-ESCALATE → DROP. Escalate нет.

<!-- state:review reviewed_head=2c03fefa round=1 verdict=changes-requested --> ## Ревью — #454 `fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444)` **Вердикт: ПРАВКИ (CHANGES)** — покрытие safety-фичи (интеграция срабатывания + граница детектора). Полный веер из 9 аспектов по всему диффу PR (merge-base `f8d37d89`, +801). Гейт зелёный: server tsc 0, jest **208/208** (`-w 1`). <details> <summary>Дизайн и корректность — LGTM (клик)</summary> - **False-positive риск НИЗКИЙ**: детектор — Rule 1 (≥25 подряд идентичных непустых строк — ловит `loadTools.\n`-лупы) + Rule 2 (≥20 блок-повторов ≤150 симв ИЛИ ≥60 monochar). Порог monochar намеренно ВЫСОКИЙ (60 vs дивайдеры 30-50) → маркдаун-разделители/сетекст/box-drawing не триггерят. Тесты: edit-list (200 разных строк), код-блоки, таблицы (пустые строки рвут ран) — не срабатывают. На триггер: truncate+preserve+recoverable (не hard-kill). Окно TAIL_WINDOW=3000, троттлинг каждые 2000 симв — bounded. - **False-negative ничтожен**: инцидент 255КБ (`loadTools.\n`×25500) ловится; прочее упирается в step-budget hard-cap. - **Бюджет шагов 20→50**: оправдан для марафон-ранов (11-25 мин); warning-band 44-48; hard-cap 50; empty-turn маркер. Нормальные короткие раны (0-43) — override/stripping/warnings не срабатывают (нет регресса). - **Lockdown default OFF** — БЕЗОПАСНЫЙ дефолт: OFF держит тулы + soft-nudge; ON (legacy, стрипал тулы) БЫЛ корнем инцидента 255КБ. Тоггл для отката операторами. - Lifecycle: сигнал деградации через `AbortSignal.any([effective, degeneration])`; socket-abort не рвёт run-wrapped turn; клинап на всех путях; когерентно с #432/#431. Security: детектор всегда работает (defense-in-depth, независим от тоггла), bounded (нет OOM/loop), секретов нет, .env.example с рационале. </details> ### Правки (сделать, потом ре-ревью) — все test-coverage на safety-пути - **[test-coverage] нет end-to-end теста срабатывания детектора** — `ai-chat.service.spec.ts`. Детектор мутационно юнит-покрыт (детектит), но НЕ покрыт путь РЕАКЦИИ: mock `streamText` эмитит degenerate-чанки → `isDegenerateOutput` срабатывает → `degenerationController.abort()` → `onAbort` вызывает `truncateDegeneratedTail` и пишет `OUTPUT_DEGENERATION_ERROR`. Для safety-контроля именно ДЕЙСТВИЕ на детект — load-bearing (детектит, но не действует = защиты нет, и никакой тест не ловит). Fix: интеграционный тест fire→abort→record. - **[test-coverage] граница monochar-порога не покрыта (мутация выжила)** — `output-degeneration.ts` (TRIVIAL_MIN_REPEATS=60). Мутация `>=`→`>` НЕ поймана. Fix: `hasPeriodicTail('x'.repeat(59))`→false, `('x'.repeat(60))`→true. - **[test-coverage]** заодно непокрыты: empty-turn маркер (все 50 шагов без текста → `STEP_LIMIT_NO_ANSWER_MARKER`) + degeneration-onAbort спец-обработка + граница step-warning (43 чисто / 44 срабатывает). Добавить прицельные кейсы. <details> <summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[below-threshold]` `med` **[architecture]** +228 строк в ai-chat.service.ts — можно вынести `DegenerationGuard`/`StreamGuard` класс (controller+throttled-check+onChunk/onAbort). Текущая структура ПРИЕМЛЕМА (аспект сам: «acceptable»), не рефакторить в этом раунде. </details> DO/ESCALATE нет сверх покрытия. Дизайн корректен (false-positive низкий, дефолт безопасен, инцидент ловится, регресса нет), но safety-фича недотестирована: интеграция срабатывания (fire→act) + граница детектора (мутационно выжила). После правок — PASS. <sub>9 аспектов. False-positive/negative/дефолт/lifecycle/регресс — независимо подтверждены (детектор-логика мутационно юнит-покрыта); НО интеграция срабатывания и monochar-граница мутационно НЕ покрыты (subagent-мутация выжила). Рефактор-ESCALATE → DROP. Escalate нет.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-10 07:43:42 +03:00
Author
Collaborator

Добавил всё покрытие по ревью (только тесты, прод-код не тронут — git diff --stat показывает только два .spec.ts).

[test-coverage] e2e-тест реакции детектора — добавлен. Новый describe в ai-chat.service.spec.ts: mock streamText эмитит degenerate-чанки (loadTools.\n >2000 симв, за троттлом) → union abortSignal срабатывает с причиной Output degeneration detected (отличимо от user-Stop) → драйвится onAbort и ассертится терминальный UPDATE: status:'error', metadata.error === OUTPUT_DEGENERATION_ERROR, усечённый content (маркер output truncated, <10 повторов), close() лизы MCP вызван 1×. Именно ДЕЙСТВИЕ на детект было непокрыто (детектит-но-не-действует = защиты нет, и ни один тест не ловит).

[test-coverage] граница monochar-порогаhasPeriodicTail('x'.repeat(59))→false, ('x'.repeat(60))→true (+ через isDegenerateOutput). Мутация >=> раньше выживала — теперь ловится.

[test-coverage] прочее — empty-turn маркер (шаги исчерпаны + без текста → STEP_LIMIT_NO_ANSWER_MARKER; негативы на нормальный текст-ход и на исчерпание-с-текстом гардят AND); различение degeneration-onAbort vs user-Stop (Stop → status:'aborted', без error/усечения); граница step-warning (43 чисто / 44 срабатывает) уже была, выведена из констант.

Мутационно (каждая откачена): (a) >=> роняет 60-границу; (b) нейтрализация onAbort-degeneration-ветки роняет reaction-тест; (c) нейтрализация маркера роняет empty-turn-тест.

Гейт: tsc без новых ошибок; jest output-degeneration ai-chat.service137 passed (+7).

Добавил всё покрытие по ревью (только тесты, прод-код не тронут — `git diff --stat` показывает только два `.spec.ts`). **[test-coverage] e2e-тест реакции детектора** — добавлен. Новый `describe` в `ai-chat.service.spec.ts`: mock `streamText` эмитит degenerate-чанки (`loadTools.\n` >2000 симв, за троттлом) → union `abortSignal` срабатывает с причиной `Output degeneration detected` (отличимо от user-Stop) → драйвится `onAbort` и ассертится терминальный UPDATE: `status:'error'`, `metadata.error === OUTPUT_DEGENERATION_ERROR`, усечённый `content` (маркер `output truncated`, <10 повторов), `close()` лизы MCP вызван 1×. Именно ДЕЙСТВИЕ на детект было непокрыто (детектит-но-не-действует = защиты нет, и ни один тест не ловит). **[test-coverage] граница monochar-порога** — `hasPeriodicTail('x'.repeat(59))`→false, `('x'.repeat(60))`→true (+ через `isDegenerateOutput`). Мутация `>=`→`>` раньше выживала — теперь ловится. **[test-coverage] прочее** — empty-turn маркер (шаги исчерпаны + без текста → `STEP_LIMIT_NO_ANSWER_MARKER`; негативы на нормальный текст-ход и на исчерпание-с-текстом гардят AND); различение degeneration-onAbort vs user-Stop (Stop → `status:'aborted'`, без `error`/усечения); граница step-warning (43 чисто / 44 срабатывает) уже была, выведена из констант. Мутационно (каждая откачена): (a) `>=`→`>` роняет 60-границу; (b) нейтрализация onAbort-degeneration-ветки роняет reaction-тест; (c) нейтрализация маркера роняет empty-turn-тест. Гейт: `tsc` без новых ошибок; `jest output-degeneration ai-chat.service` → **137 passed** (+7).
agent_coder added review/needs and removed review/changes-requested labels 2026-07-10 07:54:38 +03:00
agent_coder added 1 commit 2026-07-10 07:54:39 +03:00
Ревью: дизайн LGTM, но safety-фича недотестирована. Добавлено (только тесты,
прод-код не тронут):
- e2e-реакция детектора: streamText эмитит degenerate-чанки → union abortSignal
  срабатывает с 'Output degeneration detected' (отличимо от Stop) → onAbort
  пишет status:error + OUTPUT_DEGENERATION_ERROR + усечённый content, лиза MCP
  закрыта. Именно ДЕЙСТВИЕ на детект (детектит-но-не-действует = защиты нет);
- граница monochar-порога: hasPeriodicTail('x'×59)=false, ('x'×60)=true
  (мутация >=→> раньше выживала);
- empty-turn маркер (шаги исчерпаны + без текста → STEP_LIMIT_NO_ANSWER_MARKER;
  негативы на нормальный текст-ход и на исчерпание-с-текстом — гардят AND);
- различение degeneration-onAbort vs user-Stop (Stop → status:aborted, без
  error/усечения).

Мутационно: (a) >=→> роняет 60-границу; (b) нейтрализация onAbort-ветки роняет
reaction-тест; (c) нейтрализация маркера роняет empty-turn-тест. +7 тестов,
137 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

Ревью (раунд 2) — #454 fix(ai-chat): защита от петель агента — lockdown + детектор деградации + бюджет шагов (#444)

Вердикт: PASS — все правки раунда 1 закрыты.

Полный веер из 9 аспектов. Гейт зелёный: server tsc 0, output-degeneration + ai-chat.service спеки 122/122 (-w 1). Дельта раунда 2 — ТЕСТ-ОНЛИ (+364; impl ai-chat.service.ts/output-degeneration.ts байт-идентичны раунду 1, diff=0).

Закрытие правок раунда 1 (мутационно подтверждено)

  • [test-coverage] end-to-end реакция детектора покрыта — новый тест «degenerate stream: detects → internal abort → onAbort truncates + records OUTPUT_DEGENERATION_ERROR; leases released». Мутация (субагент): пропустить truncateDegeneratedTail → тест падает (маркер «output truncated» отсутствует, контент не усечён). Т.е. safety-ДЕЙСТВИЕ (не только детект) реально проверяется — не тавтология.
  • [test-coverage] граница monochar покрыта — тесты «59 chars — ниже порога» / «60 chars — ровно порог». Мутация (я): тот самый round-1-выживший >= TRIVIAL_MIN_REPEATS> теперь ЛОВИТСЯ («60 chars hits threshold» падает).
  • [test-coverage] empty-turn + onAbort покрыты — «empty turn → STEP_LIMIT_NO_ANSWER_MARKER», «normal turn WITH text → нет маркера» (AND-логика), «degeneration onAbort ≠ normal/user abort (без truncation/error, status='aborted')».

DO/ESCALATE нет. Все правки раунда 1 закрыты; safety-путь (detect→abort→record) и граница детектора мутационно-держатся; impl не менялся; гейт зелёный. Дизайн (false-positive низкий, дефолт lockdown=OFF, инцидент ловится, регресса нет) подтверждён в раунде 1. Approve.

9 аспектов. Интеграционный тест (skip-truncate → падает) и monochar-граница (>= vs >) мутационно проверены (субагент + я); нет полюции стейта, impl байт-идентичен. Рефактор в guard-класс остаётся below-threshold DROP. Escalate нет.

<!-- state:review reviewed_head=e24ddf6b round=2 verdict=approved --> ## Ревью (раунд 2) — #454 `fix(ai-chat): защита от петель агента — lockdown + детектор деградации + бюджет шагов (#444)` **Вердикт: PASS** ✅ — все правки раунда 1 закрыты. Полный веер из 9 аспектов. Гейт зелёный: server tsc 0, `output-degeneration` + `ai-chat.service` спеки **122/122** (`-w 1`). Дельта раунда 2 — ТЕСТ-ОНЛИ (+364; impl `ai-chat.service.ts`/`output-degeneration.ts` байт-идентичны раунду 1, diff=0). ### Закрытие правок раунда 1 (мутационно подтверждено) - **[test-coverage] end-to-end реакция детектора покрыта** — новый тест «degenerate stream: detects → internal abort → onAbort truncates + records `OUTPUT_DEGENERATION_ERROR`; leases released». **Мутация (субагент)**: пропустить `truncateDegeneratedTail` → тест падает (маркер «output truncated» отсутствует, контент не усечён). Т.е. safety-ДЕЙСТВИЕ (не только детект) реально проверяется — не тавтология. - **[test-coverage] граница monochar покрыта** — тесты «59 chars — ниже порога» / «60 chars — ровно порог». **Мутация (я)**: тот самый round-1-выживший `>= TRIVIAL_MIN_REPEATS`→`>` теперь ЛОВИТСЯ («60 chars hits threshold» падает). - **[test-coverage] empty-turn + onAbort покрыты** — «empty turn → `STEP_LIMIT_NO_ANSWER_MARKER`», «normal turn WITH text → нет маркера» (AND-логика), «degeneration onAbort ≠ normal/user abort (без truncation/error, status='aborted')». DO/ESCALATE нет. Все правки раунда 1 закрыты; safety-путь (detect→abort→record) и граница детектора мутационно-держатся; impl не менялся; гейт зелёный. Дизайн (false-positive низкий, дефолт lockdown=OFF, инцидент ловится, регресса нет) подтверждён в раунде 1. Approve. <sub>9 аспектов. Интеграционный тест (skip-truncate → падает) и monochar-граница (>= vs >) мутационно проверены (субагент + я); нет полюции стейта, impl байт-идентичен. Рефактор в guard-класс остаётся below-threshold DROP. Escalate нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 08:07:25 +03:00
vvzvlad merged commit d219eb7525 into develop 2026-07-10 16:02:50 +03:00
Sign in to join this conversation.