Агент доверяет первому совпадению: скрытый баг MCP-инструментов

Агент доверяет первому совпадению: скрытый баг MCP-инструментов

Разработчик MCP-сервера Infrawise получил комментарий к своему посту. Читатель описывал баг, о котором автор не писал, в инструменте, который тот не создавал. Пользователь спросил у codebase-intelligence сервера про символ и получил аккуратный ответ: путь к файлу, определение, список ссылок. Всё well-formed. Только ответ описывал теневую копию функции из каталога experiments/, а не настоящую из src/. Ничего не упало, ничего не устарело. Инструмент нашёл два определения с одинаковым именем и вернул то, до которого добрался первым.

Эта история из статьи Сиддхартха Панди «Your agent trusts the first match. Should it?» (dev.to, сентябрь 2026) описывает третий, редко обсуждаемый failure mode инструментов для ИИ-агентов. И он опаснее ошибок и устаревших данных вместе взятых.

Три способа, которыми контекст врёт агенту

Про первые два автора статьи уже знал и даже успел выпустить фиксы.

Первый способ: данные не прочитались. Экстрактор упал с ошибкой прав доступа, инструмент вернул пустой список, а пустой список читается как «там ничего нет». Спросите у такого инструмента, есть ли у очереди dead-letter queue, после того как SQS-экстрактор словил permissions error, и вы получите уверенное «DLQ не настроен». Это issue #101, и лечится он статусом по каждому источнику в ответе: failed read больше не маскируется под отсутствие ресурса.

Второй способ: данные устарели. Чтение прошло успешно, а потом кто-то выполнил terraform apply. Снапшот внутренне согласован и описывает аккаунт, которого больше не существует. Это issue #102: каждый ответ теперь несёт метку, когда инфраструктура была прочитана, и вызывающая сторона сама решает, насколько трёхдневной свежести ответ подходит под её вопрос.

Третий способ, issue #103, не похож ни на один из них. Чтение успешно. Данным секунды от роду. Каждое поле в ответе это реальное значение из реального источника. Просто ответ про другую функцию, и нигде в нём нет сигнала об этом. Метаданные о свежести не помогают. Статусы источников не помогают. Оба честно рапортуют: всё сработало.

Почему неправильный ответ звучит уверенно

В графе кода Infrawise узлы функций имеют file-scoped идентификаторы вида function:src/handler.ts:getOrder. То есть getOrder из src/handler.ts и getOrder из experiments/handler.ts это два genuinely разных узла с разными наборами исходящих рёбер. А lookup матчился только по имени:

const funcNode = currentGraph.nodes.find(
  (n) => n.type === 'function' && n.name === functionName,
);

.find(). Первое совпадение побеждает, молча. Какой именно узел вернётся, зависело от порядка, в котором AST-сканер обходил дерево файлов.

Теперь проследим, что делает с этим ассистент. Он вызывает analyze_function для getOrder, потому что собирается править src/handler.ts. Получает found: true, реальный путь, реальный список обращений к таблицам, реальные находки. Допустим, сканер первым дошёл до experiments/handler.ts: черновой файл дергает public.users, а настоящий обработчик делает полное сканирование public.orders с высоким приоритетом проблемы. Ассистент теперь уверен: функция трогает users, сканирования нет, индексы не нужны. Каждое его следующее решение логично, последовательно и построено на чужом файле.

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

Тот же баг этажом ниже

Когда форма проблемы стала видна, она нашлась и в AST-сканере. Issue #44: разрешение идентификатора в строковое значение делалось поиском по имени переменной по всему файлу, без учёта области видимости. Две функции в одном файле, у каждой свой const tableName, и все запросы второй функции приписывались таблице первой.

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

Фикс: перестать искать по имени и спросить type checker. Метод getSymbol() из TypeScript Compiler API разрешает идентификатор так, как это делает сам компилятор: от места вызова наружу через вложенные области видимости. Поиск по имени никогда не был разрешением. Это была догадка, которая чаще всего оказывалась верной, а это самый опасный вид неправоты.

Два разных слоя одной кодовой базы, написанные с разницей в месяцы, оба потянулись к «найди штуку с таким именем» и оба ошиблись одинаково. Это не небрежность. .find() это то, что язык подсовывает, когда вы просите lookup: он производит значение, а не жалобу. Форма API тянет вас к багу.

Кодовая база уже знала ответ

Самое неловкое в issue #103: два других пути разрешения в том же файле уже обрабатывали неоднозначность правильно. Автор решил эту задачу дважды и не применил решение в третьем месте.

При квалификации коротких имён таблиц (SQL пишет orders, а извлечённые узлы называются public.orders) коллизия записывает в мапу пустую строку как ядовитое значение. Пустая строка falsy, резолвер проваливается к placeholder-схеме вместо того, чтобы привязаться к одной из двух настоящих таблиц. Коллизия производит заметно неразрешённый узел, а не ребро, уверенно указывающее на подбрасывание монетки.

Инструмент get_table_schema использует filter, а не find: спросите про orders и получите все таблицы с таким коротким именем по всем базам, и контракт инструмента прямо это декларирует. Оба линкера, связывающие развёрнутую Lambda с исходной функцией, содержат одну и ту же строку: if (matches.length !== 1) continue;. Два кандидата означают отсутствие связи вообще. Граф предпочитает недостающее ребро неправильному.

Паттерн был установлен. analyze_function оказался единственным местом, которое его не переняло.

Вернуть развилку, а не разрешить её

Фикс мал, как обычно бывает, когда форма проблемы названа. filter вместо find. Детали по каждому файлу переезжают в массив matches: по записи на каждый исходник, определяющий функцию с таким именем, со своими file, accesses и missingPermissions. Когда совпадений больше одного, ответ несёт флаг ambiguous: true, и вызывающая сторона видит развилку, а не должна заметить, что массив стал длиннее.

Регрессионный тест намеренно буквален: строится граф с getOrder в handler.ts и getOrder в experiments/handler.ts, проверяется ambiguous === true, длина matches равна двум, и доступ второго совпадения указывает на таблицу, которую трогает только теневой файл. Вернёте .find() обратно, тест упадёт на проверке длины раньше всего остального.

То же правило этажом выше, где несколько развёрнутых Lambda делят один handler path. Повторяющийся index.handler в стеке это обычное дело, а не экзотика. В этом случае инструмент возвращает candidateLambdas с именами и уровнями уверенности и намеренно удерживает триггеры: прицепить SQS-триггер одной Lambda к коду, который делят пять, это ровно тот уверенный и неправильный ответ, ради которого всё затевалось.

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

Общее правило для проектировщиков инструментов

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

Честных форм три: вернуть всех кандидатов, вернуть ничего с объяснением причины, вернуть одного с явным маркером уверенности. Нечестна одна: вернуть одного из нескольких так, будто он единственный. Стоит начать искать, и .find() по имени обнаруживается повсюду, и каждый такой вызов это маленькое молчаливое утверждение, что имена уникальны, в коде, который прямо говорит об обратном.

Для ИИ-ассистента это важнее, чем для человека, читающего тот же вывод. Человек, получивший experiments/handler.ts в ответ на вопрос про src/handler.ts, заметит путь. Ассистент свернёт его в контекст и пойдёт дальше, и чужой файл станет посылкой для всего, что он напишет следом. Именно поэтому дизайн tool contract'ов для агентов это не вопрос удобства API, а вопрос того, какие лжи ваш инструмент способен рассказать с абсолютно уверенным видом.

Как проверить свои инструменты прямо сейчас

Аудит занимает меньше часа и не требует переписывания архитектуры. Сначала найдите в кодовой базе все вызовы поиска по имени: .find() в JavaScript и TypeScript, next(filter(...)) в Python, их аналоги в вашем языке. Для каждого задайте один вопрос: что произойдёт, если совпадений два. Если ответ «вернётся первое, и никто не узнает», вы нашли место для флага неоднозначности или отказа от ответа.

Второй шаг: посмотрите на контракты инструментов, которые вы отдаёте агентам, будь то MCP-серверы, function calling для OpenAI или внутренние плагины. Если тип возврата это одиночный объект, а домен допускает множественные совпадения (функции, таблицы, пользователи, документы, deployment'ы), контракт уже врёт по построению. Массив кандидатов с маркером уверенности стоит дороже в потреблении, но именно он отделяет инструмент, которому агент может доверять, от инструмента, который просто звучит убедительно.

Часто задаваемые вопросы

Чем «свежий и неправильный» ответ отличается от устаревшего?

Устаревшие данные и ошибки чтения ловятся метаданными: меткой времени снапшота и статусом источника. Неправильное разрешение кандидата живёт в сигнатуре функции: lookup вернул одно значение там, где совпало два, и уже уничтожил свидетельство того, что выбор вообще был. Никакие метаданные, прикрученные к ответу, это свидетельство не восстановят.

Почему нельзя просто добавить параметр file в вызов инструмента?

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

Это проблема только MCP-инструментов?

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

Итог

Инструменты для ИИ-агентов учатся сообщать о сбоях чтения и устаревании данных, но третий failure mode, уверенный ответ про не ту сущность, остаётся невидимым, потому что он не выглядит как сбой. Правило простое: если lookup может совпасть с несколькими сущностями, тип возврата обязан уметь сказать об этом. Откройте свой код и найдите все .find() по имени. Каждый из них это ставка на то, что имена уникальны. Вопрос лишь в том, заметите ли вы по выводу инструмента, что ставка проиграна.

← Все записи