# Тестирование сбоев инструментов агента: как заранее выявлять небезопасные повторы

Тестирование сбоев инструментов агента должно быть сосредоточено на том, к какому выводу приходит агент после неудачной операции, а не только на том, выдал ли инструмент ошибку. Инструмент, который после удаленной записи сообщает «запрос не выполнен», хотя запись могла завершиться, создает более серьезную проблему, чем инструмент, который останавливается с неполным ответом. Агент строит план на основе результата, который вы ему передали.

Сценарий успешного выполнения скрывает решения, от которых зависит безопасность автономного запуска: повторить, запросить подтверждение, заменить доступ, исправить данные или остановиться. Я видел наборы инструментов с сотнями зеленых тестов, в которых ни разу не проверялось отключение сети между запуском удаленной команды и возвратом ее вывода. Такие наборы не тестировали опасную часть.

## Ответ об ошибке становится входными данными для планировщика агента

Агент воспринимает результат инструмента как свидетельство. Если результат говорит, что изменений не произошло, агент может повторить операцию. Если он указывает на истекшие учетные данные, агент может искать разрешенный путь восстановления. Если он сообщает об успехе, хотя удаленный результат неизвестен, агент может построить несколько следующих действий на выдуманном основании.

Разделяйте ошибки по тому, что может знать вызывающая сторона. Это различие постоянно размывают:

- Подтвержденный отказ означает, что удаленный сервис получил запрос и отклонил его.
- Подтвержденная ошибка означает, что удаленный сервис вернул результат, из которого следует: запрошенная работа не выполнена.
- Неопределенный результат означает, что вызывающая сторона не может установить, выполнила ли удаленная сторона работу.
- Локальная ошибка означает, что инструмент завершился до того, как смог сделать содержательную попытку удаленного вызова.

Отказ в соединении до открытия TCP-сеанса обычно относится к локальной ошибке. HTTP 403 означает подтвержденный отказ. Тайм-аут чтения после отправки `POST` дает неопределенный результат, если только удаленный сервис не позволяет найти операцию по идентификатору. Эти метки должны присутствовать в тестовых случаях и схеме результата инструмента. Не прячьте их в предложении, которое агенту придется интерпретировать.

Компактная структура результата делает контракт проверяемым:

```json
{
  "ok": false,
  "category": "outcome_unknown",
  "operation": "create_deployment",
  "retry": "reconcile_first",
  "correlation_id": "case-ssh-017",
  "message": "Connection closed after the remote command started; remote completion is unknown."
}
```

Названия не так важны. Важно разделение. `retry: "never"` для отказа в разрешении и `retry: "reconcile_first"` для записи, завершившейся тайм-аутом, передают агенту разную информацию, не превращая все сообщение об ошибке в промпт.

Не возвращайте необработанные ошибки провайдера как единственный интерфейс. Они меняются, часто содержат лишний текст и иногда включают данные запроса, которые не следует передавать агенту. Сохраняйте исходные статус, тело и заголовки в защищенной диагностике. Вызывающей стороне возвращайте стабильный, намеренно небольшой результат.

## Стройте матрицу вокруг операций и свидетельств

Полезная матрица сопоставляет каждую операцию с режимами отказа, способными изменить ее смысл. Сначала перечислите инструменты, которые читают, создают, обновляют, удаляют, запускают или выполняют действия. Тайм-аут чтения требует другого восстановления, чем команда, изменяющая рабочий сервер.

Используйте эту матрицу как исходный артефакт. Замените названия операций и ожидаемые записи своими, но не удаляйте столбец «удаленный эффект известен». Он заставляет открыто рассмотреть неудобные случаи.

| Случай | Операция | Внедренное условие | Удаленный эффект известен? | Ожидаемая категория | Указание агенту |
| --- | --- | --- | --- | --- | --- |
| C01 | прочитать задачу | не удалось выполнить DNS-запрос | да, запрос не отправлен | local_failure | повторить в ограниченном бюджете |
| C02 | создать задачу | токен истек | да, запрос отклонен | authentication_failed | остановиться и запросить разрешенное восстановление учетных данных |
| C03 | удалить релиз | отказано в разрешении | да, запрос отклонен | authorization_denied | не повторять |
| C04 | прочитать сборку | JSON содержит `status: 7` | да, ответ получен | malformed_response | остановиться и сообщить о несоответствии схемы |
| C05 | создать развертывание | ответ задержан дольше клиентского срока | нет | outcome_unknown | перед повтором сверить состояние |
| C06 | выполнить перезапуск по SSH | локальный помощник завершился после запуска на удаленной стороне | нет | outcome_unknown | проверить удаленное состояние перед следующей командой |
| C07 | обновить запись | сервис возвращает 429 | да, запрос отклонен | rate_limited | подождать указанное время и повторить, если это безопасно |

Добавьте строки для операций, которые расходуют деньги, меняют разрешения, заменяют учетные данные или затрагивают общее состояние. Для них одной строки с тайм-аутом недостаточно. Проверьте тайм-аут до выхода байтов из процесса, после отправки заголовков запроса, после принятия запроса сервисом и во время получения тела ответа. Точные точки внедрения зависят от протокола, но объединение их в один общий случай «тайм-аут» лишает вас поведения, которое нужно проверить.

Для каждой строки нужны четыре проверки:

1. Проверьте категорию результата инструмента и указание о повторе.
2. Проверьте следующее действие агента, включая отсутствие самовольного разрушительного повтора.
3. Проверьте удаленное состояние или документированную причину, по которой оно остается неизвестным.
4. Проверьте, что в цепочке событий есть идентификатор корреляции и наблюдаемый результат.

Это сложнее, чем проверить `ok == false`. Зато так выявляются ошибки, которые имеют значение после того, как агент уже выполнил несколько действий.

## Истекшие учетные данные и запрещенные действия требуют разного восстановления

Истекшие или отозванные учетные данные подтверждают, что аутентификация не прошла. Запрещенное действие подтверждает, что вызывающая сторона прошла аутентификацию, но не имеет разрешения на эту операцию, если только провайдер намеренно не скрывает различие. Если свести оба случая к «доступ не выполнен», агент начнет действовать неправильно.

RFC 9110 определяет 401 как запрос без аутентификации и требует, чтобы сервер отправлял вызов `WWW-Authenticate`. Код 403 он определяет как отказ выполнить запрос, даже если сервер не сообщает причину. Провайдеры не всегда четко соблюдают это различие, поэтому тестируйте фактический ответ конкретного провайдера. При этом инструмент должен честно распределять наблюдаемые свидетельства по отдельным категориям, когда это возможно.

В тесте истекших учетных данных используйте учетную запись, которая была принята во время настройки, но отклонена во время фактического вызова. Фиктивная строка, которую сервис никогда не распознавал, проверяет только ветку невалидных учетных данных. Нужно выявить особенности кэшей, обновления токенов и сопоставления ошибок после истечения срока действия токена.

Простая фикстура может описать оба случая, не раскрывая секрет:

```yaml
cases:
  - id: expired-token
    request:
      method: POST
      path: /v1/releases
    fixture_response:
      status: 401
      headers:
        www-authenticate: Bearer error="invalid_token"
      body: {"error":"token_expired"}
    expect:
      category: authentication_failed
      retry: never
      secret_in_result: false

  - id: denied-release
    request:
      method: POST
      path: /v1/releases
    fixture_response:
      status: 403
      body: {"error":"insufficient_scope"}
    expect:
      category: authorization_denied
      retry: never
      secret_in_result: false
```

Проверка `secret_in_result` выявляет ошибку, которая появляется во время лихорадочной отладки: код прикрепляет к исключению исходящий заголовок авторизации или объект конфигурации. Тестируйте сериализованный вывод инструмента, вывод трассировки и любые данные, попадающие в расшифровку диалога агента. Маскирование в одном логере не защищает другой.

Не заставляйте агента «попробовать другие учетные данные», если система явно не предоставляет ему отдельную разрешенную личность. Слепой выбор учетных данных может пересечь границу привилегий, хотя внешне будет похож на решение проблемы доступности. Тест должен подтверждать, что ошибка аутентификации останавливает запуск или направляет его по утвержденному пути восстановления с участием человека.

## Некорректные данные требуют контрактного теста, а не теста JSON-парсера

Некорректные данные включают валидный JSON, который код не может безопасно использовать. Неверный синтаксис это простой случай. В продакшене чаще встречаются изменения типа поля, исчезновение обязательного идентификатора, замена успешного конверта конвертом ошибки или усеченный ответ после закрытия соединения прокси.

Спецификация JSON-RPC 2.0 разделяет ошибки разбора (`-32700`) и некорректные запросы (`-32600`). Это полезное разделение: оно отличает нечитаемые байты от читаемого сообщения, нарушающего протокол. Применяйте ту же дисциплину к ответам своего домена: успешный разбор не доказывает соответствие ответа контракту инструмента.

Для каждого ответа провайдера создайте фикстуры, которые нарушают одно предположение за раз:

- Замените строковый ID на `null`, число и объект.
- Уберите поле, которое следующие вызовы инструментов используют для сверки.
- Верните успешный статус с телом в форме ошибки.
- Верните статус ошибки с HTML-телом или усеченным JSON-документом.
- Продублируйте элемент или измените порядок, если код выбирает первый элемент.

Затем проверьте точное поведение. Инструмент должен назвать проблемное поле или условие контракта в защищенной диагностике, вернуть агенту `malformed_response` и не выполнять последующие изменения на основе предположенных значений.

Распространенный плохой шаблон выглядит безобидно: `response.id || request.id`. Он позволяет процессу продолжаться, если провайдер не вернул `id`, но может привести к обновлению или удалению другого объекта, если идентификаторы запроса и ответа различаются. Проверьте, что отсутствие идентификатора в ответе останавливает операцию. Неудачный процесс дешевле неправильной записи.

Клиентам инструментов MCP нужна такая же осторожность. Формат результата инструмента Model Context Protocol поддерживает сигнал `isError` для ошибки на уровне инструмента. Используйте его, когда сам инструмент не может выполнить обещанную работу, но содержание результата должно быть достаточно конкретным, чтобы агент выбрал безопасную ветку. Не маскируйте некорректный ответ вышестоящего сервиса под обычный текст, начинающийся с «Error:». Многие клиенты воспримут это как успешное выполнение инструмента и оставят агенту необходимость додумывать остальное.

## Тайм-ауты становятся неоднозначными после начала записи

Тайм-аут сообщает, что истек ваш срок ожидания. Он не определяет состояние удаленной операции. Это очевидно в теории, но в цикле повторов потерянный ответ легко превращается в дублированный счет, два развертывания или второй перезапуск.

Проверяйте поведение тайм-аута на границе, где меняется степень определенности. Инжектор ошибок или фиктивный сервис должен записывать каждый этап:

```text
case=C05 request_id=case-http-005 received=true
case=C05 request_id=case-http-005 mutation_committed=true
case=C05 response_write=delayed
client case=C05 deadline_exceeded=true
```

Проверять нужно не то, что «клиент получил тайм-аут». Проверяйте, что клиент возвращает `outcome_unknown`, не отправляет второй `POST` и перед продолжением использует проверку статуса или механизм идемпотентности.

Токены идемпотентности помогают только тогда, когда удаленный API документирует их и учитывает для нужной операции. Тестируйте полный сценарий: отправьте запрос с уникальным токеном, задержите первый ответ до момента, когда вызывающая сторона сдастся, передайте тот же токен через путь восстановления, а затем проверьте, что сервис сообщает об одной логической операции. Нельзя объявлять операцию идемпотентной только потому, что вы добавили заголовок, который провайдер игнорирует.

Для операций без конечной точки сверки или поддержки идемпотентности укажите это в результате инструмента. Безопасным действием может быть остановка и просьба к человеку проверить удаленную систему. Это не инженерная ошибка. Ошибка состоит в том, чтобы изображать уверенность только потому, что процессу хочется продолжить работу.

Если клиент поддерживает раздельные сроки, задавайте их для каждой фазы: соединение, запись запроса, первый байт ответа и общая длительность операции. Один большой срок скрывает разницу между неустановленным соединением и принятым запросом, после которого другая сторона перестала отвечать. Агенту необязательно видеть каждую фазу, но в диагностике должно быть достаточно подробностей, чтобы оператор мог воспроизвести событие.

## Прерванные удаленные команды должны сохранять неопределенность

У SSH есть окно отказа, которое разработчики HTTP часто недооценивают. Клиент может отправить команду, удаленная оболочка может запустить ее, а затем соединение закроется до получения кода завершения. Сбой локального процесса или потеря маршрута не отменяют работу, которую удаленный хост уже начал.

OpenSSH указывает, что клиент возвращает код завершения удаленной команды, когда может его получить. Если транспорт разрывается раньше, вызывающая сторона не получает этот код. Намеренно тестируйте такой случай, а не считайте ненулевой код локального процесса доказательством ошибки удаленной команды.

Создайте удаленную тестовую команду, которая записывает маркер запуска, ждет, записывает маркер завершения и сохраняет узнаваемый результат. Затем прервите локальный транспорт во время ожидания. Проводите это только на изолированном хосте или в контейнере, которым вы владеете.

```sh
# remote command used only in an isolated test environment
id="case-ssh-017"
printf '%s start\n' "$id" >> /tmp/agent-tool-test.log
sleep 20
printf '%s complete\n' "$id" >> /tmp/agent-tool-test.log
```

Запустите команду через тот же SSH-путь, который использует инструмент, дождитесь появления маркера запуска и завершите локальный помощник. После истечения удаленного ожидания проверьте журнал. Выполните тест дважды: один раз, когда удаленный процесс завершится, и один раз, когда удаленная сторона убьет его после маркера запуска. Локальное прерывание будет одинаковым, но восстановление должно отличаться.

Для команд, меняющих состояние, сначала проектируйте команду сверки, а уже потом повтор. Перезапуск сервиса может проверять время работы процесса или ревизию развертывания. Установка пакета может проверять установленную версию. Команда, которую нельзя сверить, после прерывания должна требовать явного участия человека.

Избегайте фрагментов оболочки, которые скрывают частичное выполнение за цепочками `&&` и расплывчатым выводом. Записывайте постоянный идентификатор операции до начала изменяющей части и используйте его при последующей проверке. Если удаленная среда не может сохранить никакой маркер, у инструмента нет оснований сообщать агенту, что повтор безопасен.

## Отказ человека это обычный результат, а не сломанный тест

Отказ человека должен возвращать отдельный результат и аккуратно завершать эту ветку действия. Команды часто проверяют появление экрана подтверждения, но забывают ветку отказа. В результате агент повторяет запрос, переформулирует его или принимает отказ в подтверждении за сетевую проблему.

Тестируйте отказ на каждой открытой вами границе авторизации. Проверьте, что после отказа инструмент не подключается к удаленному сервису. Проверьте, что он не сохраняет подтверждение для следующего процесса или другого действия, требующего нового решения. Проверьте, что агент получает формулировку, которую может использовать, не воспринимая отказ как приглашение найти обходной путь.

Хранилище Sallyport блокирует каждое действие, пока остается закрытым, а подтверждения сессии и отдельных вызовов позволяют тестировать эти решения, не помещая учетные данные в процесс агента. Такое разделение полезно: заблокированное хранилище, отклоненная сессия и отказ в отдельном вызове могут остановить операцию по разным причинам.

Усталость от подтверждений сама по себе является ошибкой тестирования. Если безобидное чтение во время обычного запуска вызывает повторяющиеся запросы, люди начнут одобрять их, не читая. Если разрушительный вызов случайно унаследует широкое подтверждение, человек не увидит ожидаемой точки принятия решения. Проверяйте количество, время и область действия запросов, а не только их наличие.

Используйте тестового агента, который выполняет одно разрешенное действие, одно запрещенное и еще одно после завершения процесса. Последний вызов выявляет утечку состояния подтверждения за пределы нужной сессии. Не ограничивайтесь переключением логического значения в памяти. Запускайте новый процесс, чтобы тест проходил тот же жизненный цикл, что и реальный пользовательский запуск.

## Логи должны объяснять произошедшее, не раскрывая доступ

Полезная запись об ошибке позволяет восстановить причинно-следственную связь: какой запуск агента пытался выполнить какую операцию, какой идентификатор запроса использовал, что увидела удаленная система, что вернул инструмент и что агент сделал дальше. В ней не должны храниться учетные данные, разрешившие вызов.

Записывайте событие в каждой точке, где ответ может измениться. Для записи с тайм-аутом фиксируйте создание запроса, начало отправки, принятие удаленной стороной, если это может сообщить фикстура, истечение срока, попытку сверки и окончательную классификацию. Добавляйте идентификатор корреляции, созданный до первого сетевого действия. Не выводите его из секрета и не используйте повторно для разных операций.

Такой формат подходит для локального тестового стенда:

```json
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"dispatch_started"}
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"remote_committed"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"client_timeout"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"result","category":"outcome_unknown"}
```

После этого проверка может сопоставить записи клиента и фикстуры по `id`. Если фикстура сообщает `remote_committed`, а инструмент возвращает `confirmed_failure`, тест должен завершиться ошибкой. Такое расхождение выявляет небезопасное утверждение, даже если все ветки кода вернули аккуратный объект ошибки.

Для защищенной от незаметного изменения цепочки проверяйте и саму верификацию. Sallyport формирует записи сессий и действий из зашифрованного аудиторского журнала, связанного цепочкой хешей, а `sp audit verify` проверяет цепочку офлайн без ключа хранилища. Тест на ошибку должен добавить известную последовательность событий, проверить ее, изменить копию записи и убедиться, что проверка измененной копии завершается ошибкой.

Не помещайте полные тела запросов в обычные логи по умолчанию. В запросах могут быть персональные данные, исходный код или токены, случайно добавленные вызывающей стороной. Записывайте имя операции, категорию цели, идентификатор корреляции, категорию результата и ссылку на защищенную диагностику. Расширяйте сбор данных только в контролируемой тестовой среде, где известно содержимое фикстур.

## Тестируйте восстановление агента, а не только адаптер

Модульные тесты подтверждают, что адаптер преобразует 403 в `authorization_denied`. Они не подтверждают, что агент остановится после получения этого результата. Запустите небольшой сквозной набор тестов со стабильной инструкцией агента и фиктивным удаленным сервисом, который показывает журнал внедренных событий.

Давайте каждому запуску узкую задачу и четкую границу. Например: создать одну запись, прочитать ее, затем добавить заметку. Задержите ответ на создание после фиксации записи. Правильное поведение агента состоит в том, чтобы сначала проверить запись по идентификатору корреляции или токену идемпотентности, а не отправлять второй запрос на создание. Тест должен завершаться ошибкой, если агент создает еще одну запись, даже если в итоге задача выполнена.

Для этого набора сохраняйте промпт агента неизменным. Если одновременно изменить промпт, контракт инструмента, поведение фикстуры и версию модели, по результату ошибки будет трудно понять причину. Записывайте расшифровку вызовов инструмента и следующий вызов агента, а затем сравнивайте их с разрешенными переходами:

```text
create -> outcome_unknown -> lookup_by_request_id -> found -> attach_note
create -> outcome_unknown -> create
```

Первый переход разрешен только после того, как проверка подтвердит исходное создание. Второй является ошибкой. Такая проверка автомата состояний полезнее оценки того, насколько разумно звучал итоговый текстовый ответ.

Запускайте детерминированные случаи матрицы при каждом изменении кода инструментов, схем результатов, обработки авторизации или логики повторов. Случаи прерываний регулярно повторяйте на изолированной инфраструктуре, поскольку результат зависит от планирования. При появлении нового инцидента добавляйте в матрицу минимальное воспроизведение до исправления. Иначе тот же привлекательный, но неправильный путь восстановления вернется при следующем рефакторинге.

Стандарт для инструмента состоит не в том, чтобы продолжать работу после каждой ошибки. Важно, чтобы он честно сообщал, что ему известно, оставлял свидетельства и не превращал неопределенность во второе разрушительное действие.
