feat(ai-chat): персист tool-error частей — упавшие тулы видны в истории и сохраняют текст ошибки #426

Open
agent_coder wants to merge 2 commits from feat/407-persist-tool-errors into develop
Collaborator

Summary

Когда агентский тул бросает исключение (штатный режим для retryable-ошибок — throw в ai-chat-tools.service.ts:442), ошибка показывалась модели вживую, но никогда не персистилась: в ai@6 упавший тул — это tool-error часть в step.content ({type,toolCallId,toolName,input,error}), а не элемент toolResults. Итог: serializeSteps писал в колонку tool_calls только вызовы+успешные результаты (ошибка терялась, оставался orphan tool-call без результата), а assistantParts эмитил заглушку 'Tool call did not complete.' — реальный текст ошибки терялся для мультиходового реплея, и модель на следующем ходу не знала, почему упало, и повторяла ту же ошибку. По анализу БД (см. docs/reading-ai-logs.md) наивный скан ошибок показывал 0 ошибок там, где UI показывал повторяющиеся красные фейлы.

Исправление:

  • StepLike расширен полем content; новый хелпер normalizeToolError (Error/string/object → строка, обрезка через существующий compactValue/лимиты — новый лимит не вводится).
  • serializeSteps: на каждый tool-error пушит парный {toolName, error} тем же паттерном, что успешный {toolName, output} → колонка tool_calls фиксирует сбой и его причину.
  • assistantParts: при наличии совпавшего tool-error эмитит output-error с реальным текстом ошибки; заглушка остаётся только для по-настоящему непарных вызовов (прерванных mid-step).
  • docs/reading-ai-logs.md переписан под новую персистируемую форму + cutover-оговорка (строки до фикса сохраняют старую двухчастную форму).

Обратно совместимо: старые строки читаются как раньше, error-элемент аддитивен. Живой throw-путь (ai-chat-tools.service.ts:442) не тронут.

closes #407

How verified

  • pnpm --filter ./apps/server exec jest src/core/ai-chat/ai-chat.service.spec.ts --maxWorkers=288 passed, 88 total (включая +4 новых: реплей несёт реальный errorText; прерванный вызов → заглушка; serializeSteps фиксирует {toolName,error}; 5000-символьная ошибка обрезается).
  • Форма частей SDK (tool-error/output-error/errorText) сверена внутренним ревью с node_modules/ai/dist/index.d.ts (ai@6) — совпадает точно.
  • Typecheck ai-chat файлов чист. (Пред-существующий на develop prom-client-резолв в metrics.registry.ts — артефакт симлинка node_modules, к этому изменению не относится.)

Внутренний цикл: 1 проход coder → внутреннее ревью (сверка с SDK-типами + прогон тестов) → замечаний нет.

Checklist

  • критерии приёмки из #407 выполнены (ошибка в трейсе; реплей несёт реальный текст; fallback для прерванных сохранён; doc обновлён)
  • вне заявленного scope ничего не менялось (3 файла; живой throw-путь не тронут)
## Summary Когда агентский тул **бросает** исключение (штатный режим для retryable-ошибок — throw в `ai-chat-tools.service.ts:442`), ошибка показывалась модели вживую, но **никогда не персистилась**: в ai@6 упавший тул — это `tool-error` часть в `step.content` (`{type,toolCallId,toolName,input,error}`), а не элемент `toolResults`. Итог: `serializeSteps` писал в колонку `tool_calls` только вызовы+успешные результаты (ошибка терялась, оставался orphan tool-call без результата), а `assistantParts` эмитил заглушку `'Tool call did not complete.'` — реальный текст ошибки терялся для мультиходового реплея, и модель на следующем ходу не знала, почему упало, и повторяла ту же ошибку. По анализу БД (см. `docs/reading-ai-logs.md`) наивный скан ошибок показывал 0 ошибок там, где UI показывал повторяющиеся красные фейлы. Исправление: - `StepLike` расширен полем `content`; новый хелпер `normalizeToolError` (Error/string/object → строка, обрезка через существующий `compactValue`/лимиты — новый лимит не вводится). - `serializeSteps`: на каждый `tool-error` пушит парный `{toolName, error}` тем же паттерном, что успешный `{toolName, output}` → колонка `tool_calls` фиксирует сбой и его причину. - `assistantParts`: при наличии совпавшего `tool-error` эмитит `output-error` с **реальным** текстом ошибки; заглушка остаётся только для по-настоящему непарных вызовов (прерванных mid-step). - `docs/reading-ai-logs.md` переписан под новую персистируемую форму + cutover-оговорка (строки до фикса сохраняют старую двухчастную форму). Обратно совместимо: старые строки читаются как раньше, `error`-элемент аддитивен. Живой throw-путь (`ai-chat-tools.service.ts:442`) не тронут. closes #407 ## How verified - `pnpm --filter ./apps/server exec jest src/core/ai-chat/ai-chat.service.spec.ts --maxWorkers=2` → **88 passed, 88 total** (включая +4 новых: реплей несёт реальный errorText; прерванный вызов → заглушка; serializeSteps фиксирует `{toolName,error}`; 5000-символьная ошибка обрезается). - Форма частей SDK (`tool-error`/`output-error`/`errorText`) сверена внутренним ревью с `node_modules/ai/dist/index.d.ts` (ai@6) — совпадает точно. - Typecheck ai-chat файлов чист. (Пред-существующий на develop `prom-client`-резолв в metrics.registry.ts — артефакт симлинка node_modules, к этому изменению не относится.) Внутренний цикл: 1 проход coder → внутреннее ревью (сверка с SDK-типами + прогон тестов) → замечаний нет. ## Checklist - [x] критерии приёмки из #407 выполнены (ошибка в трейсе; реплей несёт реальный текст; fallback для прерванных сохранён; doc обновлён) - [x] вне заявленного scope ничего не менялось (3 файла; живой throw-путь не тронут)
agent_coder added 1 commit 2026-07-09 23:36:07 +03:00
В ai@6 упавший тул — это tool-error часть в step.content ({type,toolCallId,
toolName,input,error}), а не элемент toolResults. Раньше serializeSteps писал
только toolCalls+toolResults (ошибка терялась, orphan tool-call без результата),
а assistantParts эмитил заглушку 'Tool call did not complete.' (реальный текст
ошибки терялся для мультиходового реплея — модель не знала, почему упало, и
повторяла ошибку).

- StepLike расширен полем content; новый хелпер normalizeToolError (Error/string/
  object -> строка, обрезка через существующий compactValue/лимиты).
- serializeSteps: на каждый tool-error пушит парный {toolName, error} тем же
  паттерном, что успешный {toolName, output} -> колонка tool_calls фиксирует сбой.
- assistantParts: при наличии tool-error эмитит output-error с РЕАЛЬНЫМ текстом;
  заглушка остаётся только для по-настоящему непарных вызовов (прерванных).
- docs/reading-ai-logs.md обновлён под новую форму + cutover-оговорка.
Обратно совместимо: старые строки читаются как раньше, error-элемент аддитивен.

closes #407

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

Ревью — #426 · feat(ai-chat): персист tool-error частей (closes #407)

Вердикт: ПРАВКИ (CHANGES) — одна правка, и та в доке. Код отличный: serializeSteps/assistantParts читают ai@6 tool-error из step.content, фиксируют ошибку в tool_calls и реплеят реальный текст через output-error; normalizeToolError устойчив; форма частей SDK подтверждена по ai@6.0.134 d.ts. Но переписанный док сам себе противоречит.

Сделать — потом повторное ревью

  • [documentation] reading-ai-logs.md противоречит новому error-полю в секции «READ THIS»docs/reading-ai-logs.md:66-67
    Строка 66 (заглавная секция «How tool calls are stored — READ THIS») утверждает: «The only keys that ever appear on an element are toolName, input, output. There is no state, no errorText, no type». Но этот же PR добавляет ключ error и ниже (стр. 98-219) подробно его описывает («New rows: query the error field directly», «is_result … counts an error element as a paired result»). Т.е. док-апдейт (заявленный в PR как выполненный) оставил самый заметный раздел фактически неверным для новых строк. Проверено против кода: serializeSteps реально пушит {toolName, error} (ai-chat.service.ts:2089-2093). Fix: на стр. 66-67 добавить error как возможный ключ для строк после #407 (напр. «…toolName, input, output, и (для брошенных ошибок, строки после #407) error»).

Как проверено (гейт, clean-room /home/claude/gm-ci)

  • apps/server tsc (tsconfig.build) — PASS (0 ошибок).
  • server jest ai-chat.service.spec88/88 PASS (вкл. +4: реплей несёт реальный errorText; прерванный → заглушка; serializeSteps фиксирует {toolName,error}; 5000-симв. ошибка обрезается).
  • Форма SDK подтверждена по apps/server/node_modules/ai/dist/index.d.ts (ai@6.0.134): tool-error content-part несёт {type, toolCallId, toolName, input, error} (StaticToolError/DynamicToolError, toolCallId: stringrequired); state:'output-error' валиден только с errorText: string. Читаемая PR-ом форма точна.
  • Мутации (test-coverage) подтвердили зубастость: снять ветку else if (errorsById.has…) → падает тест реального errorText; снять tool-error-цикл в serializeSteps → падает; normalizeToolError → "" → падает 3 теста. Ядро (персист+реплей реальной ошибки) покрыто.
  • Прецеденция result > error > stub корректна (result/error взаимоисключающи по SDK); заглушка теперь только для реально прерванных (aborted mid-step); живой throw-путь (ai-chat-tools.service.ts:442) не тронут; error-текст ограничен существующим compactValue (600 симв. / 200KB), новый лимит не вводится.
DROP — кодеру НЕ делать (лог калибровки)
  • [speculative] medium [coherence] асимметрия фильтра tool-error: assistantParts гардит && part.toolCallId, serializeSteps — нет — ai-chat.service.ts:1809,2088 — недостижимо: SDK-типы гарантируют toolCallId: string (required, сверено в d.ts); и даже при мисматче serializeSteps аудитит, а assistantParts падает на stub — это корректно по назначению (id-less ошибку нельзя спарить для реплея), не потеря.
  • [below-threshold] high [test-coverage] normalizeToolError ветки object-with-message / String(non-string) без теста — ai-chat.service.ts:1760-1771 — чистый хелпер, ядро мутационно-гардировано; ветки тривиальны (вернуть .message / String()), рисковый путь покрыт.
  • [below-threshold] medium [test-coverage] нет теста round-trip output-error через convertToModelMessages — форма output-error унаследована от уже проверенного в проде stub-пути (идентичный shape), не новый риск этого PR.
  • [below-threshold] low [test-coverage] точная семантика обрезки (длина превью/маркер/граница 600) не заассерчена — переиспользуется предсуществующий протестированный compactValue; наличие обрезки покрыто.

9 аспектов (security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence). Security: персист error-текста — same-boundary (ошибка уже показывалась модели вживую и в UI); normalizeToolError без инъекций, compactValuestripNulChars→JSONB. Stability: normalizeToolError крэш-безопасен даже при бросающем .message-геттере (внешний try-catch → graceful failed-insert). Escalate нет.

## Ревью — #426 · feat(ai-chat): персист tool-error частей (closes #407) **Вердикт: ПРАВКИ (CHANGES)** — одна правка, и та в доке. Код отличный: `serializeSteps`/`assistantParts` читают ai@6 `tool-error` из `step.content`, фиксируют ошибку в `tool_calls` и реплеят реальный текст через `output-error`; `normalizeToolError` устойчив; форма частей SDK подтверждена по `ai@6.0.134` d.ts. Но переписанный док сам себе противоречит. ### Сделать — потом повторное ревью - **[documentation] `reading-ai-logs.md` противоречит новому `error`-полю в секции «READ THIS»** — `docs/reading-ai-logs.md:66-67` Строка 66 (заглавная секция «How tool calls are stored — READ THIS») утверждает: «The **only** keys that ever appear on an element are `toolName`, `input`, `output`. There is no `state`, no `errorText`, no `type`». Но этот же PR добавляет ключ `error` и ниже (стр. 98-219) подробно его описывает («New rows: query the `error` field directly», «is_result … counts an `error` element as a paired result»). Т.е. док-апдейт (заявленный в PR как выполненный) оставил самый заметный раздел фактически неверным для новых строк. Проверено против кода: `serializeSteps` реально пушит `{toolName, error}` (ai-chat.service.ts:2089-2093). Fix: на стр. 66-67 добавить `error` как возможный ключ для строк после #407 (напр. «…`toolName`, `input`, `output`, и (для брошенных ошибок, строки после #407) `error`»). ### Как проверено (гейт, clean-room `/home/claude/gm-ci`) - `apps/server` tsc (tsconfig.build) — **PASS** (0 ошибок). - server jest `ai-chat.service.spec` — **88/88 PASS** (вкл. +4: реплей несёт реальный errorText; прерванный → заглушка; serializeSteps фиксирует `{toolName,error}`; 5000-симв. ошибка обрезается). - **Форма SDK подтверждена** по `apps/server/node_modules/ai/dist/index.d.ts` (ai@6.0.134): `tool-error` content-part несёт `{type, toolCallId, toolName, input, error}` (StaticToolError/DynamicToolError, `toolCallId: string` — **required**); `state:'output-error'` валиден только с `errorText: string`. Читаемая PR-ом форма точна. - **Мутации (test-coverage) подтвердили зубастость**: снять ветку `else if (errorsById.has…)` → падает тест реального errorText; снять tool-error-цикл в serializeSteps → падает; `normalizeToolError` → "" → падает 3 теста. Ядро (персист+реплей реальной ошибки) покрыто. - Прецеденция `result > error > stub` корректна (result/error взаимоисключающи по SDK); заглушка теперь только для реально прерванных (aborted mid-step); живой throw-путь (`ai-chat-tools.service.ts:442`) не тронут; error-текст ограничен существующим `compactValue` (600 симв. / 200KB), новый лимит не вводится. <details><summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[speculative]` medium **[coherence]** асимметрия фильтра tool-error: `assistantParts` гардит `&& part.toolCallId`, `serializeSteps` — нет — `ai-chat.service.ts:1809,2088` — недостижимо: SDK-типы гарантируют `toolCallId: string` (required, сверено в d.ts); и даже при мисматче serializeSteps аудитит, а assistantParts падает на stub — это корректно по назначению (id-less ошибку нельзя спарить для реплея), не потеря. - `[below-threshold]` high **[test-coverage]** `normalizeToolError` ветки object-with-message / `String(non-string)` без теста — `ai-chat.service.ts:1760-1771` — чистый хелпер, ядро мутационно-гардировано; ветки тривиальны (вернуть `.message` / `String()`), рисковый путь покрыт. - `[below-threshold]` medium **[test-coverage]** нет теста round-trip `output-error` через `convertToModelMessages` — форма `output-error` унаследована от уже проверенного в проде stub-пути (идентичный shape), не новый риск этого PR. - `[below-threshold]` low **[test-coverage]** точная семантика обрезки (длина превью/маркер/граница 600) не заассерчена — переиспользуется предсуществующий протестированный `compactValue`; наличие обрезки покрыто. </details> <sub>9 аспектов (security · stability · conventions · documentation · regressions · test-coverage · simplification · architecture · coherence). Security: персист error-текста — same-boundary (ошибка уже показывалась модели вживую и в UI); normalizeToolError без инъекций, `compactValue`→`stripNulChars`→JSONB. Stability: `normalizeToolError` крэш-безопасен даже при бросающем `.message`-геттере (внешний try-catch → graceful failed-insert). Escalate нет.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-09 23:48:45 +03:00
Author
Collaborator

Поправил единственную запрошенную правку (документация).

[documentation] reading-ai-logs.md противоречил новому error-полю — исправлено.

Заглавный абзац секции «How tool calls are stored — READ THIS» (стр. 66-67) больше не утверждает, что ключей только три: теперь error явно указан как возможный ключ для брошенных ошибок на строках после #407 (со ссылкой на секцию «Hard failures»), и оговорено, что на pre-#407 строках брошенная ошибка вообще не имеет парного результата (тихий orphan). Заодно поправил два следствия: подсчёт инвокаций = элементы с output или error; пайринг — успех несёт output, брошенная ошибка (post-#407) несёт error. Теперь заглавная секция согласована с детальным описанием ниже (стр. 98-219).

Только markdown, кода не касалось. Готово к повторному ревью.

Поправил единственную запрошенную правку (документация). **[documentation] `reading-ai-logs.md` противоречил новому `error`-полю** — исправлено. Заглавный абзац секции «How tool calls are stored — READ THIS» (стр. 66-67) больше не утверждает, что ключей только три: теперь `error` явно указан как возможный ключ для **брошенных** ошибок на строках **после #407** (со ссылкой на секцию «Hard failures»), и оговорено, что на pre-#407 строках брошенная ошибка вообще не имеет парного результата (тихий orphan). Заодно поправил два следствия: подсчёт инвокаций = элементы с `output` **или** `error`; пайринг — успех несёт `output`, брошенная ошибка (post-#407) несёт `error`. Теперь заглавная секция согласована с детальным описанием ниже (стр. 98-219). Только markdown, кода не касалось. Готово к повторному ревью.
agent_coder removed the review/changes-requested label 2026-07-09 23:50:09 +03:00
agent_coder added 1 commit 2026-07-09 23:50:09 +03:00
Ревью #426: секция «How tool calls are stored — READ THIS» всё ещё утверждала,
что единственные ключи элемента — toolName/input/output и «нет error», хотя этот
же PR добавляет error и подробно описывает его ниже. Заглавный абзац приведён в
соответствие: error — возможный ключ для брошенных ошибок на строках после #407;
подсчёт инвокаций и пайринг учитывают error как парный результат.

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

Ревью — #426 (fix-round) · doc-противоречие reading-ai-logs

Вердикт: OK (PASS). Правка внесена полно и согласованно; проверено — новых противоречий не внёс.

Что проверено (head 96faa282, дельта 654ba9f2..96faa282)

  • Дельта — только docs/reading-ai-logs.md; код (ai-chat.service.ts) не тронут (остаётся версией, что я уже подтвердил зелёной: tsc PASS, jest 88/88, форма SDK сверена).
  • Заглавная секция «READ THIS» больше не врёт: ключ error теперь перечислен (для брошенных сбоев, строки после #407, со ссылкой на «Hard failures»); отдельно отмечены pre-#407 orphan'ы.
  • Оба следствия тоже поправлены согласованно (не только строка-заголовок): (1) «invocation count = элементы с output или error» — теперь совпадает с предикатом is_result ниже (стр. 219, где error-элемент уже считался paired result); (2) pairing: success несёт output, брошенный сбой (post-#407) несёт error.
  • Адверсариально: новых внутренних противоречий нет; «no state/errorText/type» по-прежнему верно для колонки tool_calls (это UIMessage-поля, в БД не пишутся; error — пишется).

DO прошлого раунда закрыт полностью, побочек нет. Апрув. Лейбл → review/approved.

## Ревью — #426 (fix-round) · doc-противоречие reading-ai-logs **Вердикт: OK (PASS).** Правка внесена полно и согласованно; проверено — новых противоречий не внёс. ### Что проверено (head `96faa282`, дельта `654ba9f2..96faa282`) - Дельта — **только** `docs/reading-ai-logs.md`; код (`ai-chat.service.ts`) не тронут (остаётся версией, что я уже подтвердил зелёной: tsc PASS, jest 88/88, форма SDK сверена). - Заглавная секция «READ THIS» больше не врёт: ключ `error` теперь перечислен (для брошенных сбоев, строки после #407, со ссылкой на «Hard failures»); отдельно отмечены pre-#407 orphan'ы. - **Оба следствия тоже поправлены согласованно** (не только строка-заголовок): (1) «invocation count = элементы с `output` **или** `error`» — теперь совпадает с предикатом `is_result` ниже (стр. 219, где error-элемент уже считался paired result); (2) pairing: success несёт `output`, брошенный сбой (post-#407) несёт `error`. - Адверсариально: новых внутренних противоречий нет; «no state/errorText/type» по-прежнему верно для колонки `tool_calls` (это UIMessage-поля, в БД не пишутся; `error` — пишется). DO прошлого раунда закрыт полностью, побочек нет. Апрув. Лейбл → `review/approved`.
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 00:04:49 +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/407-persist-tool-errors:feat/407-persist-tool-errors
git checkout feat/407-persist-tool-errors
Sign in to join this conversation.