fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444) #454
Open
agent_coder
wants to merge 2 commits from
fix/444-agent-loop-guards into develop
pull from: fix/444-agent-loop-guards
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:refactor/448-generate-inventory
vvzvlad:refactor/445-execute-mapping
vvzvlad:refactor/446-derive-client-types
vvzvlad:fix/447-registry-stamp-ci
vvzvlad:fix/396-sendnow-detached-run
vvzvlad:fix/452-immutable-cache-control
vvzvlad:develop
vvzvlad:docs/footnote-authoring-comment-cleanup
vvzvlad:feat/437-error-diagnostics
vvzvlad:fix/442-cursor-pagination
vvzvlad:feat/419-footnote-normalize
vvzvlad:feat/424-drawio-rules
vvzvlad:perf/399-comment-resolve-async
vvzvlad:perf/435-collab-token-cache
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/423-drawio-crud
vvzvlad:perf/401-collab-hotpath
vvzvlad:feat/430-live-reconnect
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/346-compression-cache
vvzvlad:feat/196-multi-cursor
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#454
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "fix/444-agent-loop-guards"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Защита от третьего класса петли агента (инцидент 2026-07-10, чат
019f49d8…): ран упёрся в 20-шаговый кап, спалив все шаги на чтение (48× searchInPage, 0× createComment); на 20-м шаге final-step lockdown отнял инструменты посреди незаконченной работы → модель выродилась в текст-повтор («loadTools.» ×20416, 255КБ, ~24 мин генерации). Пакет защит по дизайну владельца (lockdown по умолчанию выключаем,maxOutputTokensНЕ вводим — tool-аргументы это выходные токены; единственная защита от болтовни — детектор деградации).closes #444
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).onChunk(единственная анти-болтовня, БЕЗmaxOutputTokens): чистые правила (output-degeneration.ts) — ≥25 одинаковых строк ИЛИ периодический хвост; при срабатывании abort через внутреннийAbortController∪ effectiveSignal (AbortSignal.any), финализация вonAbort: усечение деградировавшего хвоста,ai_chat_runs.error = Output degeneration detected…(отличимо от Stop/рестарта), лизы MCP и снапшоты освобождаются (существующий lifecycle).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
maxOutputTokensне выставляется ни на одном шагеРевью — #454
fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444)Вердикт: ПРАВКИ (CHANGES) — покрытие safety-фичи (интеграция срабатывания + граница детектора).
Полный веер из 9 аспектов по всему диффу PR (merge-base
f8d37d89, +801). Гейт зелёный: server tsc 0, jest 208/208 (-w 1).Дизайн и корректность — LGTM (клик)
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.loadTools.\n×25500) ловится; прочее упирается в step-budget hard-cap.AbortSignal.any([effective, degeneration]); socket-abort не рвёт run-wrapped turn; клинап на всех путях; когерентно с #432/#431. Security: детектор всегда работает (defense-in-depth, независим от тоггла), bounded (нет OOM/loop), секретов нет, .env.example с рационале.Правки (сделать, потом ре-ревью) — все test-coverage на safety-пути
ai-chat.service.spec.ts. Детектор мутационно юнит-покрыт (детектит), но НЕ покрыт путь РЕАКЦИИ: mockstreamTextэмитит degenerate-чанки →isDegenerateOutputсрабатывает →degenerationController.abort()→onAbortвызываетtruncateDegeneratedTailи пишетOUTPUT_DEGENERATION_ERROR. Для safety-контроля именно ДЕЙСТВИЕ на детект — load-bearing (детектит, но не действует = защиты нет, и никакой тест не ловит). Fix: интеграционный тест fire→abort→record.output-degeneration.ts(TRIVIAL_MIN_REPEATS=60). Мутация>=→>НЕ поймана. Fix:hasPeriodicTail('x'.repeat(59))→false,('x'.repeat(60))→true.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 нет.
Добавил всё покрытие по ревью (только тесты, прод-код не тронут —
git diff --statпоказывает только два.spec.ts).[test-coverage] e2e-тест реакции детектора — добавлен. Новый
describeвai-chat.service.spec.ts: mockstreamTextэмитит degenerate-чанки (loadTools.\n>2000 симв, за троттлом) → unionabortSignalсрабатывает с причиной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).Ревью: дизайн 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>Ревью (раунд 2) — #454
fix(ai-chat): защита от петель агента — lockdown + детектор деградации + бюджет шагов (#444)Вердикт: PASS ✅ — все правки раунда 1 закрыты.
Полный веер из 9 аспектов. Гейт зелёный: server tsc 0,
output-degeneration+ai-chat.serviceспеки 122/122 (-w 1). Дельта раунда 2 — ТЕСТ-ОНЛИ (+364; implai-chat.service.ts/output-degeneration.tsбайт-идентичны раунду 1, diff=0).Закрытие правок раунда 1 (мутационно подтверждено)
OUTPUT_DEGENERATION_ERROR; leases released». Мутация (субагент): пропуститьtruncateDegeneratedTail→ тест падает (маркер «output truncated» отсутствует, контент не усечён). Т.е. safety-ДЕЙСТВИЕ (не только детект) реально проверяется — не тавтология.>= TRIVIAL_MIN_REPEATS→>теперь ЛОВИТСЯ («60 chars hits threshold» падает).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 нет.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.