feat(mcp): внятная диагностика ошибок тулов — серверное сообщение вместо «Request failed with status code N» + fail-fast валидация id (вкл. #436) #437

Open
opened 2026-07-10 05:07:25 +03:00 by agent_vscode · 0 comments
Collaborator

Объединяет #436 (fail-fast по обрезанному UUID) и общий механизм диагностики. Это полная спека решения.

1. Проблема

Модель получает от упавших тулов бесполезные тексты и не может самокорректироваться → слепые ретраи → петли (инцидент 2026-07-10: 24/38 упавших createComment за один ран, флуд дублей комментариев).

Реальные кейсы:

  • resolve_comment с обрезанным UUID 019f499a-9f8c → сервер режет на @IsUUID() → модель видит «Request failed with status code 400»;
  • любой 404 (getPage несуществующей страницы) → «Request failed with status code 404» вместо «Page not found»;
  • сетевые ошибки → «timeout of 30000ms exceeded» без указания, какой запрос упал.

2. Как ошибка доходит до модели (исследовано, оба транспорта)

Текст ошибки, который видит модель, — это всегда error.message брошенного исключения:

  • Внешний MCP (packages/mcp/src/index.ts): throw из execute → MCP SDK превращает в isError-ответ с error.message;
  • In-app агент: throw из execute → ai@6 tool-error part → normalizeToolError() (apps/server/src/core/ai-chat/ai-chat.service.ts ~1760) берёт только .message; после #407 этот же текст персистится и реплеится.

Единственная авторитетная точка, где рождается текст, — DocmostClient (packages/mcp/src/client.ts) и lib/collaboration.ts. Чиним там — чинится везде. Слои index.ts / ai-chat-tools.service / normalizeToolError НЕ трогаем.

3. Таксономия ошибок клиента (инвентаризация)

Класс Где Состояние
A. REST/axios ~22 сырых this.client.post/get/… «Request failed with status code N» — чиним (фаза A)
B. Невалидный вход, ловится до запроса commentId-параметры уходит на сервер и падает как класс A — чиним (фаза B, бывш. #436)
C. Collab-запись mutatePageContent/CollabSession: connect/persist timeout, connection closed, auth failed ⚠️ текст есть, но без pageId и без подсказки — дополняем (фаза C)
D. Доменные ошибки якоря create_comment, edit_page_text failed[], Yjs-encode (unstorableYjsError уже называет offending attribute) уже actionable — не трогаем
E. Спец-обёртки downloadImage/upload (отдельный bare-axios инстанс, тело скрыто сознательно) не трогаем, автоматически вне интерцептора
F. Passthrough внешних MCP ошибки приходят контентом, не throw вне скоупа

4. Проектное решение

Фаза A — центральный axios-интерцептор диагностики

Один response-интерцептор на this.client в конструкторе DocmostClient, зарегистрированный после re-login-интерцептора.

Ключевое решение: НЕ подменять объект ошибки, а мутировать error.message (+ флаг error._docmostFormatted от повторной обработки). Причина: по клиенту есть живые проверки axios.isAxiosError(e) / error.response?.status (~строки 362, 389, 1245, 3042–3054 — ensureAuthenticated, ретраи, upload re-login) и config._retry; подмена на кастомный класс их сломает, мутация — нет.

Формат сообщения:

<METHOD> <path> failed (<status> <statusText>): <serverMessage>

пример: POST /comments/resolve failed (400 Bad Request): commentId must be a UUID

Правила сборки serverMessage:

  • data.message: массив (class-validator) → join через '; '; строка → как есть;
  • фолбэк data.error, затем statusText;
  • data — Buffer/ArrayBuffer (наш fetchInternalFile ходит с responseType:"arraybuffer") → guarded JSON.parse(buf.toString()) c кэпом размера, при неудаче — statusText;
  • data — строка/HTML (502 от прокси) → в сообщение НЕ включать, только statusText;
  • общий кэп длины сообщения ~300 символов;
  • path — без хоста и query;
  • нет error.response (ECONNREFUSED/ETIMEDOUT/ECONNRESET) → <METHOD> <path> failed: <error.code ?? error.message> (no response from server).

Безопасность: сообщение собирается только из message/error/statusText — сырое тело, заголовки (Authorization!) и config в текст не попадают никогда; полное тело — только в stderr под DEBUG (паритет с image-путями).

Взаимодействие с re-login: интерцепторы axios выполняются в порядке регистрации; ретрай re-login создаёт новый запрос → новый AxiosError → формируется заново, двойной обработки нет (флаг — страховка).

Фаза B — fail-fast валидация id (поглощает #436)

Хелпер в client.ts:

// Throws an actionable error when `value` is not a full UUID, BEFORE any network call.
function assertFullUuid(tool: string, param: string, value: string): void

Текст: resolve_comment: '<param>' must be the FULL comment UUID (36 chars, e.g. 019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '<value>'. Copy the id verbatim from list_comments / create_comment output.

Точки применения (все commentId-параметры): resolveComment, updateComment, deleteComment, getComment, createComment.parentCommentId. pageId НЕ валидируем — он легитимно принимает slugId (resolvePageId).

Фаза C — обогащение collab-ошибок

В lib/collaboration.ts (+ CollabSession после #431) добавить в тексты connect-timeout / persist-timeout / connection-closed: pageId и короткую подсказку (transient — retry once; persistent failures mean the collab server is unreachable/overloaded). Yjs-encode не трогаем — уже хорош.

5. Порядок работ

  1. Фаза A (интерцептор + тесты) — основной эффект;
  2. Фаза B (assertFullUuid + тесты) — маленькая, можно тем же PR;
  3. Фаза C — отдельным коммитом.

6. Тесты

Раннер node --test (packages/mcp/test/unit/*.test.mjs, образец — auth-cookie.test.mjs), новый error-diagnostics.test.mjs:

  • message-массив → join; message-строка; только error; пустое тело → statusText;
  • HTML-тело → HTML в сообщении отсутствует;
  • Buffer-тело с JSON → распарсен; Buffer-мусор → statusText;
  • нет response → code + путь + «no response»;
  • кэп длины;
  • 401 → re-login → успешный ретрай (message не тронут); 401 → re-login → повторный отказ → формат применён, _retry-логика не сломана;
  • assertFullUuid: валидный UUID проходит; префикс/мусор/пустая строка — падают с ожидаемым текстом; все 5 точек применения покрыты (без сетевого вызова — мок не должен быть вызван).

7. Критерии приёмки

  • Невалидный commentId в любом comment-туле → ошибка с текстом «FULL comment UUID» без сетевого запроса.
  • 400/404 от любого REST-эндпоинта доходит до модели с серверным message (проверка руками: resolve_comment с мусорным id после обхода фазы B; get_comment с несуществующим UUID → «Comment not found»).
  • Существующие потребители AxiosError (re-login, upload-ретрай, ensureAuthenticated) работают как раньше — код их не меняется вообще.
  • downloadImage/upload — поведение не изменилось (bare axios, вне интерцептора).
  • В тексте ошибки нет тела ответа, заголовков, токенов.

8. Проверка эффекта после деплоя

SQL из docs/reading-ai-logs.md (hard-error query по elem ? 'error'): поле error должно содержать реальные тексты формата <METHOD> <path> failed (…): …; доля заглушки Tool call did not complete. должна упасть до чистых абортов. Плюс глазами: топ error-текстов по тулам за первые дни.

Контекст

Найдено при разборе инцидента «агенты в петле» 2026-07-10 (флуд дублей комментариев). Связка: #407 (персист tool-error — канал доставки текста, который это ишью наполняет смыслом), #427 (подсказки якоря), #431 (CollabSession). Поглощает #436.

Объединяет #436 (fail-fast по обрезанному UUID) и общий механизм диагностики. Это полная спека решения. ## 1. Проблема Модель получает от упавших тулов бесполезные тексты и не может самокорректироваться → слепые ретраи → петли (инцидент 2026-07-10: 24/38 упавших `createComment` за один ран, флуд дублей комментариев). Реальные кейсы: - `resolve_comment` с обрезанным UUID `019f499a-9f8c` → сервер режет на `@IsUUID()` → модель видит «Request failed with status code 400»; - любой 404 (`getPage` несуществующей страницы) → «Request failed with status code 404» вместо «Page not found»; - сетевые ошибки → «timeout of 30000ms exceeded» без указания, какой запрос упал. ## 2. Как ошибка доходит до модели (исследовано, оба транспорта) Текст ошибки, который видит модель, — это **всегда `error.message` брошенного исключения**: - **Внешний MCP** (`packages/mcp/src/index.ts`): throw из execute → MCP SDK превращает в `isError`-ответ с `error.message`; - **In-app агент**: throw из execute → ai@6 `tool-error` part → `normalizeToolError()` (`apps/server/src/core/ai-chat/ai-chat.service.ts` ~1760) берёт только `.message`; после #407 этот же текст персистится и реплеится. ⇒ **Единственная авторитетная точка, где рождается текст, — `DocmostClient` (`packages/mcp/src/client.ts`) и `lib/collaboration.ts`.** Чиним там — чинится везде. Слои index.ts / ai-chat-tools.service / normalizeToolError НЕ трогаем. ## 3. Таксономия ошибок клиента (инвентаризация) | Класс | Где | Состояние | | --- | --- | --- | | A. REST/axios | ~22 сырых `this.client.post/get/…` | ❌ «Request failed with status code N» — **чиним (фаза A)** | | B. Невалидный вход, ловится до запроса | commentId-параметры | ❌ уходит на сервер и падает как класс A — **чиним (фаза B, бывш. #436)** | | C. Collab-запись | `mutatePageContent`/CollabSession: connect/persist timeout, connection closed, auth failed | ⚠️ текст есть, но без pageId и без подсказки — **дополняем (фаза C)** | | D. Доменные ошибки | якоря `create_comment`, `edit_page_text` failed[], Yjs-encode (`unstorableYjsError` уже называет offending attribute) | ✅ уже actionable — не трогаем | | E. Спец-обёртки | `downloadImage`/upload (отдельный bare-axios инстанс, тело скрыто сознательно) | ✅ не трогаем, автоматически вне интерцептора | | F. Passthrough внешних MCP | ошибки приходят контентом, не throw | ✅ вне скоупа | ## 4. Проектное решение ### Фаза A — центральный axios-интерцептор диагностики Один response-интерцептор на `this.client` в конструкторе `DocmostClient`, зарегистрированный **после** re-login-интерцептора. **Ключевое решение: НЕ подменять объект ошибки, а мутировать `error.message`** (+ флаг `error._docmostFormatted` от повторной обработки). Причина: по клиенту есть живые проверки `axios.isAxiosError(e)` / `error.response?.status` (~строки 362, 389, 1245, 3042–3054 — ensureAuthenticated, ретраи, upload re-login) и `config._retry`; подмена на кастомный класс их сломает, мутация — нет. Формат сообщения: ``` <METHOD> <path> failed (<status> <statusText>): <serverMessage> ``` пример: `POST /comments/resolve failed (400 Bad Request): commentId must be a UUID` Правила сборки `serverMessage`: - `data.message`: массив (class-validator) → join через `'; '`; строка → как есть; - фолбэк `data.error`, затем `statusText`; - `data` — Buffer/ArrayBuffer (наш `fetchInternalFile` ходит с `responseType:"arraybuffer"`) → guarded `JSON.parse(buf.toString())` c кэпом размера, при неудаче — statusText; - `data` — строка/HTML (502 от прокси) → в сообщение НЕ включать, только statusText; - общий кэп длины сообщения ~300 символов; - `path` — без хоста и query; - нет `error.response` (ECONNREFUSED/ETIMEDOUT/ECONNRESET) → `<METHOD> <path> failed: <error.code ?? error.message> (no response from server)`. Безопасность: сообщение собирается только из `message`/`error`/`statusText` — сырое тело, заголовки (Authorization!) и config в текст не попадают никогда; полное тело — только в stderr под `DEBUG` (паритет с image-путями). Взаимодействие с re-login: интерцепторы axios выполняются в порядке регистрации; ретрай re-login создаёт новый запрос → новый AxiosError → формируется заново, двойной обработки нет (флаг — страховка). ### Фаза B — fail-fast валидация id (поглощает #436) Хелпер в client.ts: ```ts // Throws an actionable error when `value` is not a full UUID, BEFORE any network call. function assertFullUuid(tool: string, param: string, value: string): void ``` Текст: `` resolve_comment: '<param>' must be the FULL comment UUID (36 chars, e.g. 019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '<value>'. Copy the id verbatim from list_comments / create_comment output. `` Точки применения (все `commentId`-параметры): `resolveComment`, `updateComment`, `deleteComment`, `getComment`, `createComment.parentCommentId`. `pageId` НЕ валидируем — он легитимно принимает slugId (`resolvePageId`). ### Фаза C — обогащение collab-ошибок В `lib/collaboration.ts` (+ CollabSession после #431) добавить в тексты connect-timeout / persist-timeout / connection-closed: `pageId` и короткую подсказку (`transient — retry once; persistent failures mean the collab server is unreachable/overloaded`). Yjs-encode не трогаем — уже хорош. ## 5. Порядок работ 1. Фаза A (интерцептор + тесты) — основной эффект; 2. Фаза B (assertFullUuid + тесты) — маленькая, можно тем же PR; 3. Фаза C — отдельным коммитом. ## 6. Тесты Раннер `node --test` (`packages/mcp/test/unit/*.test.mjs`, образец — `auth-cookie.test.mjs`), новый `error-diagnostics.test.mjs`: - message-массив → join; message-строка; только `error`; пустое тело → statusText; - HTML-тело → HTML в сообщении отсутствует; - Buffer-тело с JSON → распарсен; Buffer-мусор → statusText; - нет response → `code` + путь + «no response»; - кэп длины; - 401 → re-login → успешный ретрай (message не тронут); 401 → re-login → повторный отказ → формат применён, `_retry`-логика не сломана; - `assertFullUuid`: валидный UUID проходит; префикс/мусор/пустая строка — падают с ожидаемым текстом; все 5 точек применения покрыты (без сетевого вызова — мок не должен быть вызван). ## 7. Критерии приёмки - Невалидный `commentId` в любом comment-туле → ошибка с текстом «FULL comment UUID» без сетевого запроса. - 400/404 от любого REST-эндпоинта доходит до модели с серверным `message` (проверка руками: `resolve_comment` с мусорным id после обхода фазы B; `get_comment` с несуществующим UUID → «Comment not found»). - Существующие потребители AxiosError (re-login, upload-ретрай, ensureAuthenticated) работают как раньше — код их не меняется вообще. - `downloadImage`/upload — поведение не изменилось (bare axios, вне интерцептора). - В тексте ошибки нет тела ответа, заголовков, токенов. ## 8. Проверка эффекта после деплоя SQL из `docs/reading-ai-logs.md` (hard-error query по `elem ? 'error'`): поле `error` должно содержать реальные тексты формата `<METHOD> <path> failed (…): …`; доля заглушки `Tool call did not complete.` должна упасть до чистых абортов. Плюс глазами: топ error-текстов по тулам за первые дни. ## Контекст Найдено при разборе инцидента «агенты в петле» 2026-07-10 (флуд дублей комментариев). Связка: #407 (персист tool-error — канал доставки текста, который это ишью наполняет смыслом), #427 (подсказки якоря), #431 (CollabSession). Поглощает #436.
agent_vscode changed title from feat(mcp): внятная диагностика ошибок REST для всех тулов — извлекать серверное сообщение вместо «Request failed with status code N» to feat(mcp): внятная диагностика ошибок тулов — серверное сообщение вместо «Request failed with status code N» + fail-fast валидация id (вкл. #436) 2026-07-10 05:12:39 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#437