perf(ai-chat): дисциплина данных — дедуп tool-outputs + токен-бюджет реплея (#490) #510

Open
agent_coder wants to merge 6 commits from feat/490-data-discipline into feat/489-tool-resilience
Collaborator

Summary

Итерация 5 эпика #497 — дисциплина данных: дедуп tool-outputs + токен-бюджет реплея. closes #490. 6 коммитов по порядку.

  1. Формат трейса tool_calls v2 — outputs больше НЕ дублируются в трейсе (только в metadata.parts): {toolName,input} + {ok:true}/{error,kind:thrown|interrupted}, пейринг по toolCallId. Старые строки НЕ мигрируются (маркер эры metadata.toolTraceVersion:2); docs/reading-ai-logs.md переписан dual-shape.
  2. Мемоизация compactToolOutput по identity шага (WeakMap) — один stringify на шаг, не O(N²).
  3. Токен-бюджет реплея (@docmost/token-estimate, chars/2.5; клиентский счётчик переведён на тот же модуль) + trimHistoryForReplay (детерминированный, byte-stable, пейринг сохранён) + реактивная ветка overflow.
  4. Deferred-активация тулов в ai_chats.metadata (миграция 20260707T120000, строго новее всех существующих — коллизии таймстампа нет).
  5. snapshotOpenPage fast-path по updatedAt.
  6. checkNewComments параллелизм (кап 6, порядок сохранён).

Осознанная развилка (реактивная ветка) — нужно решение

Спека требовала in-turn ре-трай (переполнение → агрессивный ре-трим → ОДИН ретрай в том же ходу). Это архитектурно несовместимо: pipeUIMessageStreamToResponse вызывает response.writeHead(200) СИНХРОННО в начале пайпинга (проверено в ai@6.0.207 dist), overflow-400 приходит через onError уже после коммита заголовков — in-turn ре-пайп портит SSE-поток. Реализовано как next-turn: классифицировать 400 → штамп metadata.replayOverflow → следующий ход режет агрессивно (0.5×, игнорируя устаревший usage). Инвариант «чат не брикуется НАВСЕГДА» держится (каждый следующий ход восстанавливается), но ПЕРВЫЙ переполняющий ход всё же отдаёт ошибку (деградация против прозрачного in-turn). Внутреннее ревью форк подтвердило как корректный.
Известное ограничение (follow-up, не блокер): если реальное окно модели < 50k И chatContextWindow не задан — плоская доля 0.5× не эскалирует → остаточный брик. Строго лучше базы (там был тотальный брик), чинится конфигом, warn-логируется. Стоит завести follow-up на эскалацию доли / деривацию дефолта из окна модели.

How verified

  • Реальный WAL-LSN тест объёма записи (live pg, несжимаемый payload, 50 шагов): трейс-колонка v1=140.2MB → v2=0.04MB (3206×), полная строка 294.7MB → 140.3MB (−52%) — в диапазоне 127–510MB из ишью.
  • Полный серверный сьют jest --maxWorkers=2: 212 suites / 2513 passed / 0 failed (из чистого состояния). mcp node --test test/unit test/mock: 833/833. Клиентский оценщик + token-estimate parity зелёные.
  • Реактивная ветка — тест на реальных формах 400 (OpenAI/Anthropic/nested) + негативы; детерминизм/пейринг бюджета покрыты.
  • Server tsc: 0 ошибок в тронутых файлах (3 предсущ. drawio import.meta на базе).

Checklist

  • критерии приёмки из #490 выполнены
  • вне заявленного scope ничего не менялось

Стоит стопкой на #489 (PR #508, approved) → #487#486. Стенду нужен pnpm --filter server migration:latest (новая колонка ai_chats.metadata).

## Summary Итерация 5 эпика #497 — дисциплина данных: дедуп tool-outputs + токен-бюджет реплея. closes #490. 6 коммитов по порядку. 1. **Формат трейса `tool_calls` v2** — outputs больше НЕ дублируются в трейсе (только в `metadata.parts`): `{toolName,input}` + `{ok:true}`/`{error,kind:thrown|interrupted}`, пейринг по `toolCallId`. Старые строки НЕ мигрируются (маркер эры `metadata.toolTraceVersion:2`); `docs/reading-ai-logs.md` переписан dual-shape. 2. **Мемоизация `compactToolOutput`** по identity шага (WeakMap) — один stringify на шаг, не O(N²). 3. **Токен-бюджет реплея** (`@docmost/token-estimate`, chars/2.5; клиентский счётчик переведён на тот же модуль) + `trimHistoryForReplay` (детерминированный, byte-stable, пейринг сохранён) + **реактивная ветка** overflow. 4. **Deferred-активация тулов в `ai_chats.metadata`** (миграция `20260707T120000`, строго новее всех существующих — коллизии таймстампа нет). 5. **snapshotOpenPage fast-path** по `updatedAt`. 6. **checkNewComments параллелизм** (кап 6, порядок сохранён). ## Осознанная развилка (реактивная ветка) — нужно решение Спека требовала in-turn ре-трай (переполнение → агрессивный ре-трим → ОДИН ретрай в том же ходу). Это **архитектурно несовместимо**: `pipeUIMessageStreamToResponse` вызывает `response.writeHead(200)` СИНХРОННО в начале пайпинга (проверено в `ai@6.0.207` dist), overflow-400 приходит через `onError` уже после коммита заголовков — in-turn ре-пайп портит SSE-поток. Реализовано как **next-turn**: классифицировать 400 → штамп `metadata.replayOverflow` → следующий ход режет агрессивно (0.5×, игнорируя устаревший usage). Инвариант «чат не брикуется НАВСЕГДА» держится (каждый следующий ход восстанавливается), но ПЕРВЫЙ переполняющий ход всё же отдаёт ошибку (деградация против прозрачного in-turn). Внутреннее ревью форк подтвердило как корректный. **Известное ограничение (follow-up, не блокер):** если реальное окно модели < 50k И `chatContextWindow` не задан — плоская доля 0.5× не эскалирует → остаточный брик. Строго лучше базы (там был тотальный брик), чинится конфигом, warn-логируется. Стоит завести follow-up на эскалацию доли / деривацию дефолта из окна модели. ## How verified - **Реальный WAL-LSN тест объёма записи** (live pg, несжимаемый payload, 50 шагов): трейс-колонка **v1=140.2MB → v2=0.04MB (3206×)**, полная строка **294.7MB → 140.3MB (−52%)** — в диапазоне 127–510MB из ишью. - Полный серверный сьют `jest --maxWorkers=2`: **212 suites / 2513 passed / 0 failed** (из чистого состояния). mcp `node --test test/unit test/mock`: **833/833**. Клиентский оценщик + token-estimate parity зелёные. - Реактивная ветка — тест на реальных формах 400 (OpenAI/Anthropic/nested) + негативы; детерминизм/пейринг бюджета покрыты. - Server `tsc`: 0 ошибок в тронутых файлах (3 предсущ. drawio `import.meta` на базе). ## Checklist - [x] критерии приёмки из #490 выполнены - [x] вне заявленного scope ничего не менялось Стоит стопкой на #489 (PR #508, approved) → #487 → #486. Стенду нужен `pnpm --filter server migration:latest` (новая колонка `ai_chats.metadata`).
agent_coder added 6 commits 2026-07-11 11:55:40 +03:00
Каждый tool-output хранился ДВАЖДЫ: в metadata.parts (assistantParts) И в
tool_calls (serializeSteps). При 50-шаговом ране с outputs по 50–200 KB это
127–510 МБ записи в Postgres за ход (+WAL/TOAST/dead tuples), т.к. onStepFinish
переписывает всю строку. Копия в parts — та, что реально реплеится модели и
рендерится UI/markdown-экспортом, так что копия в трейсе была чистым дублем.

Новый формат элементов tool_calls (v2), парно на каждый вызов:
  {toolName, input}                      — вызов
  {toolName, ok: true}                   — успех (БЕЗ output)
  {toolName, error, kind: 'thrown'}      — брошенный tool-error
  {toolName, error, kind: 'interrupted'} — прерван mid-step (abort/restart)

kind обязателен: синтетический «Tool call did not complete.» при прерывании иначе
неотличим от реального hard-fail и загрязняет error-rate. Различие структурное
(errorsById-хит против синтетической ветки), НЕ per-tool классификатор — soft-
маркеры в трейс не выносятся (остаются в metadata.parts).

metadata.toolTraceVersion: 2 — маркер эры; старые строки НЕ мигрируются
(перезапись гигантских jsonb — тот самый WAL-чарн). serializeSteps пейрит
результаты/ошибки по toolCallId (как assistantParts); общая константа
TOOL_CALL_INCOMPLETE_TEXT держит текст реплея и трейса в синхроне.

docs/reading-ai-logs.md переписан dual-shape: ветвление по toolTraceVersion,
soft-анализ v2 через metadata.parts, правило «не сравнивать агрегаты через границу
эр». UI action-log и markdown-экспорт читают только parts — не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
compactToolOutput делает JSON.stringify каждого output на КАЖДОМ flush. Т.к.
onStepFinish на шаге N перестраивает всю assistant-строку по всем N накопленным
шагам, а каждый output — 50–200 KB, это O(N²) stringify за ход.

Мемоизация по identity шага: finished-шаг в capturedSteps неизменен и держит
стабильную ссылку между flush'ами, поэтому его parts (и дорогой stringify output)
строятся ровно раз за ход. buildStepParts вынесен в чистую функцию; assistantParts
принимает опциональный StepPartsCache (WeakMap<step, parts>), flushAssistant
пробрасывает его, stream() заводит один WeakMap на ход и передаёт во все flush'и.
Промах кэша (или его отсутствие в тестах/легаси-вызовах) просто пересобирает —
байтового расхождения нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат
рано или поздно упирается в контекстное окно и получает провайдерский 400 на
каждом ходу — навсегда (чат «кирпичится»). Бюджетер ограничивает РЕПЛЕЙ (никогда
не мутирует персист — в БД остаётся полная запись), детерминированно и byte-stable
(обрезанный префикс идентичен от хода к ходу → дружелюбно к prompt-cache).

Единый оценщик chars/2.5 (кириллица; chars/4 занижает вдвое) вынесен в shared-пакет
packages/token-estimate; клиентский count-stream-tokens.ts переведён на него ТЕМ ЖЕ
коммитом (два расходящихся оценщика = «бейдж 60%, а бюджет уже режет»).

history-budget.ts (чистый, покрыт тестами):
- resolveReplayBudget(raw): min(100k, 0.7×window) при заданном окне; флэт 100k при
  незаданном (именно эти инсталляции ловят терминальный overflow — warn-лог); 0 =
  явный off-switch. Читается СЫРОЙ chatContextWindow, т.к. parsePositiveInt схлопывает
  0 и unset в undefined (новое поле ResolvedAiConfig.chatContextWindowRaw).
- trimHistoryForReplay: первичный сигнал — провайдерский факт metadata.contextTokens
  прошлого хода; chars-оценка — дельта/раскройка/фолбэк. Порядок: обрезка tool-outputs
  старых ходов (head+tail+маркер) → механическое схлопывание старейших ходов
  (конкатенация, НЕ LLM) → текущий + последние N ходов всегда полные. Пейринг
  tool-call/result сохраняется (схлопывание убирает ОБЕ части).
- isContextOverflowError: классификация провайдерского 400 (статус + паттерны).

Реактивная ветка: превентивная оценка не даёт инварианта (первый переполняющий ход
не имеет usage). onError классифицирует context-overflow → пишет различимую причину
и штампует metadata.replayOverflow; следующий ход бюджетер режет агрессивно
(0.5×), что и раскирпичивает чат. Наблюдаемость: metadata.replayTrimmedToTokens.

ПРИМЕЧАНИЕ по реактивной ветке (форк, требует решения ревьюера): истинный in-turn
re-pipe (перезапуск streamText в тот же ответ) архитектурно несовместим с текущим
пайпом — pipeUIMessageStreamToResponse пишет writeHead СИНХРОННО (подтверждено в
ai@6.0.207), а suite ожидает await stream() c моком, не дёргающим колбэки, — так что
отложенный пайп/ожидание сигнала повесит тесты. Поэтому реализована реактивная
рекавери «классификация → штамп → агрессивный ре-трим на следующем ходу», что даёт
тот же инвариант (чат не кирпичится) без рискованного рефактора стрима.

Тесты (наблюдаемые свойства): объём записи через дельту pg_current_wal_lsn() на живой
gitmost-test-pg вокруг 50-шагового прогона (несжимаемые payload'ы) — trace-колонка
v1=140МБ → v2=0.04МБ (в 3206× меньше), полная строка 289МБ → 140МБ (−51%); dual-shape
не нужен здесь; «окно не задано → бюджет применяется»; реактивная классификация на
реальном 400-шейпе; parity клиент/сервер оценщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Активированный set сбрасывался каждый ход → модель заново гоняла loadTools, чтобы
переактивировать те же тулы (лишний round-trip на каждом ходу). Теперь набор
персистится в metadata чата и сидируется на следующем ходу.

- Миграция: jsonb-колонка metadata на ai_chats (default '{}'); db.d.ts дополнен
  вручную (AiChats.metadata: Generated<Json>).
- seedActivatedTools(metadata, validDeferredNames): читает сохранённый набор,
  ПЕРЕСЕКАЯ с актуальными validDeferredNames — смена allowlist/ролей не воскресит
  несуществующий тул (иначе prepareAgentStep получил бы фантомное активное имя).
  Сид только при deferredEnabled.
- Персист на завершении хода (once-guard, во всех терминальных ветках рядом со
  snapshotTurnEnd): детерминированно отсортированный набор, merge в существующий
  bag (другие ключи сохраняются), запись пропускается если ничего нового не
  активировано (обычный ход не даёт лишней записи).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
snapshotOpenPage делал полный экспорт Markdown + upsert каждый ход. Fast-path: если
снапшот уже существует на ТЕКУЩЕЙ версии страницы (тот же instant updated_at), его
контент уже актуален — пропускаем экспорт+upsert целиком. Ход, не тронувший
открытую страницу (частый случай), больше не делает работы по снапшоту.

Зеркалит read-side fast-path в detectPageChange (sameInstant): оба доверяют, что
правка страницы двигает updated_at. Когда агент/человек ПРАВИЛ страницу этим ходом,
updated_at продвинулся → не совпадает → экспортируем как раньше (правки агента
запекаются в снапшот, инвариант #274 сохранён).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
checkNewComments делал O(N) последовательных REST-вызовов listComments по страницам
working set — большой space линеен по round-trip'ам. Теперь per-page фетчи идут с
ограниченным параллелизмом (cap 6, середина полосы 5–8): независимые чтения не ждут
друг друга, но и не заваливают сервер/сокеты.

mapWithConcurrency — крошечный пул без зависимости от p-limit: N воркеров тянут
следующий индекс с общего курсора. Порядок результатов сохраняется (по входному
порядку страниц), поэтому вывод детерминирован независимо от того, какой фетч
завершился первым. Серверный batch-эндпоинт «comments updated since T по space» —
опционально, отдельным заходом.

Тест (mock-HTTP): 13 страниц, задержанный /api/comments — maxInFlight > 1 и <= 6
(последовательная реализация дала бы 1), порядок результатов = порядок обхода.

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

На ревью. Итерация 5 (#490) — дисциплина данных. Полное описание и главная развилка в теле PR; здесь — на что смотреть в первую очередь + калибровка.

Два потенциальных блокера — оба закрыты внутренним ревью (проверено независимо, не по прозе):

  • Форк реактивной ветки (next-turn вместо in-turn): архитектурное обоснование подтверждено по dist ai@6.0.207pipeUIMessageStreamToResponse синхронно коммитит writeHead(200) до того, как overflow-400 приходит через onError; in-turn ре-пайп невозможен без порчи SSE. Next-turn восстанавливает инвариант «не брикуется навсегда» end-to-end (errored-строка не фильтруется из истории, следующий ход видит флаг и режет 0.5×). Первый overflow-ход всё же отдаёт ошибку — деградация против спеки, но не путь брика, которого спека бы избежала.
  • Коллизия таймстампа миграции (класс #361): 20260707T120000-ai-chat-metadata строго новее всех существующих (новейшая — 20260706T120000-search-lookup-trgm); ни бэк-дейта, ни дубликата. Специально проверял после того, как ровно этот класс всплыл на соседнем PR.

Известное ограничение (follow-up, НЕ блокер): плоская агрессивная доля 0.5× не эскалирует → при окне модели <50k И незаданном chatContextWindow остаётся остаточный брик. Строго лучше базы, чинится конфигом, warn-логируется. Предлагаю follow-up-тикет на эскалацию доли / деривацию дефолта из реального окна модели — на твоё усмотрение.

Внутренний цикл (калибровка): 1 проход ре-ревью, блокеров не внесено. Ключевое проверено на ЖИВОЙ БД: WAL-LSN дельта показала трейс-колонку 140.2MB→0.04MB (3206×) и полную строку −52% на несжимаемом payload (не мок — мок мерил бы не то, что болит). Гейт: 212 сьютов / 2513 passed + mcp 833. Стоит стопкой на #508 (approved).

На ревью. Итерация 5 (#490) — дисциплина данных. Полное описание и главная развилка в теле PR; здесь — на что смотреть в первую очередь + калибровка. **Два потенциальных блокера — оба закрыты внутренним ревью (проверено независимо, не по прозе):** - **Форк реактивной ветки** (next-turn вместо in-turn): архитектурное обоснование подтверждено по dist `ai@6.0.207` — `pipeUIMessageStreamToResponse` синхронно коммитит `writeHead(200)` до того, как overflow-400 приходит через `onError`; in-turn ре-пайп невозможен без порчи SSE. Next-turn восстанавливает инвариант «не брикуется навсегда» end-to-end (errored-строка не фильтруется из истории, следующий ход видит флаг и режет 0.5×). Первый overflow-ход всё же отдаёт ошибку — деградация против спеки, но не путь брика, которого спека бы избежала. - **Коллизия таймстампа миграции** (класс #361): `20260707T120000-ai-chat-metadata` строго новее всех существующих (новейшая — `20260706T120000-search-lookup-trgm`); ни бэк-дейта, ни дубликата. Специально проверял после того, как ровно этот класс всплыл на соседнем PR. **Известное ограничение (follow-up, НЕ блокер):** плоская агрессивная доля 0.5× не эскалирует → при окне модели <50k И незаданном `chatContextWindow` остаётся остаточный брик. Строго лучше базы, чинится конфигом, warn-логируется. Предлагаю follow-up-тикет на эскалацию доли / деривацию дефолта из реального окна модели — на твоё усмотрение. **Внутренний цикл (калибровка):** 1 проход ре-ревью, блокеров не внесено. Ключевое проверено на ЖИВОЙ БД: WAL-LSN дельта показала трейс-колонку 140.2MB→0.04MB (3206×) и полную строку −52% на несжимаемом payload (не мок — мок мерил бы не то, что болит). Гейт: 212 сьютов / 2513 passed + mcp 833. Стоит стопкой на #508 (approved).
agent_coder added the review/needs label 2026-07-11 11:56:12 +03:00
This pull request can be merged automatically.
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 feat/490-data-discipline:feat/490-data-discipline
git checkout feat/490-data-discipline
Sign in to join this conversation.