Я делаю агента, который умеет дописывать себе навыки. Не в смысле «подключает плагины из маркетплейса», а буквально: упёрся в то, чего не умеет — сгенерировал код, собрал, установил, пользуется.
Первый раз это сработало примерно так, как я и мечтал. Агент сказал «у меня нет такого навыка», спросил разрешения, минуту пыхтел компилятором и отчитался: готово, метод доступен.
Метода не было.
То есть он был — в манифесте. В списке, который скилл про себя рассказывает. А в коде, который этот вызов должен был обрабатывать, его не было. Модель объявила метод, забыла его реализовать и об этом не узнала. Валидация прошла. Установка прошла. Агент честно доложил об успехе и пошёл дальше жить с навыком, который на половину своих обещаний отвечает «не знаю такого».
Дальше — про то, как это чинится, и почему чинится не так, как кажется.
Проблема не в том, что модель ошиблась
Модели ошибаются, это скучно и ожидаемо. Интересно другое: я не смог поймать ошибку, потому что спрашивал у того же, кто её сделал.
Смотрите, как устроен плагин. Скилл — отдельный процесс, общается по JSON‑RPC. При старте он представляется: имя, версия, список методов. Список выглядит так:
{ "method": "forgotten", "description": "Declared here and handled nowhere", "args_description": "anything, it is never read" }
Этот манифест — единственный источник знаний о том, что скилл умеет. Ядро читает его и на основании этого списка составляет для модели описание доступных действий.
А теперь вопрос: кто написал манифест? Та же модель, что и код. В одном ответе, одним куском. Если она забыла ветку в execute(), она с той же вероятностью не забыла строчку в списке методов — это же две разные части файла, и вторая проще.
Получается круг. Скилл заявляет о себе сам, проверить заявление можно только у него же, а он в этом вопросе — заинтересованная сторона. Классическое «спросите у подсудимого, виновен ли он».
При этом единственное действие, которое мне доступно снаружи — вызвать метод и посмотреть на ответ. Никакой рефлексии: скилл может быть на Python, на Node, на Rust, это чужой процесс за трубой. Разобрать его код я не могу.
Отсюда та задача, которую пришлось решать:
Как проверить, что программа не врёт о своих возможностях, если единственное доступное действие — спросить её саму?
Наивный вариант, который не работает
Первое, что приходит в голову: ну вызови каждый заявленный метод и посмотри, не упало ли.
Вызываем forgotten. Скилл отвечает:
SkillError: unknown method: forgotten (kind=NotFound)
Ответ есть? Есть. Процесс жив? Жив. Труба работает, JSON валидный, соединение не оборвалось. С точки зрения «вызвал и посмотрел, не упало ли» — всё прекрасно.
Именно на этом я и попался в первый раз. Скилл ответил на проверочный вызов, и это засчиталось за рабочий метод. Ошибка была не в том, что проверка сломалась. Проверка отработала штатно и вынесла вердикт «годен».
Проблема глубже: я не знал, как у этого скилла выглядит «нет».
Один скилл на неизвестный метод кидает NotFound. Другой возвращает пустую строку. Третий — {"error": "..."} со статусом 200. Четвёртый вообще отвечает "ok", потому что автор не заморачивался. Я не могу отличить «метод отработал» от «метода нет», пока не знаю диалект конкретного скилла.
А диалект я не знаю, потому что скилл написала модель полчаса назад и больше таких в мире нет.
Отрицательный контроль
Решение приехало из места, где я его не ждал — из школьной методики эксперимента.
Если ты не знаешь, как выглядит отрицательный результат, ты его получаешь специально. Ставишь контрольную пробирку, в которой заведомо ничего не должно произойти, смотришь, что там, и дальше сравниваешь с ней все остальные.
В коде это выглядит так:
const NONSENSE_METHOD: &str = "__jumabek_probe_no_such_method__";
Перед тем как проверять что‑либо настоящее, валидатор зовёт вот это. Метода с таким именем не существует ни у одного скилла — а если существует, автор заслужил всё, что с ним произойдёт дальше.
Ответ на этот вызов и есть эталон. Теперь известно, как конкретно этот скилл говорит «нет». И дальше каждый заявленный метод сверяется с эталоном:
for method in methods { match call_execute(client, method).await { None => { /* перестал отвечать — это отдельная беда */ } Some(Verdict::Unknown) => { report.add( "implements the methods it declares", false, format!( "'{}' is declared but the skill answers it the same way it answers a \ method that does not exist — it was never wired into execute()", method ), ); return; } Some(Verdict::Answered) => {} } }
Логика целиком: заявленный метод не имеет права отвечать так же, как метод, которого нет. Если отвечает — он объявлен и не подключён. Всё, поймали.
Меня в этой конструкции подкупает, что она ничего не знает о скиллах. Ни про язык, ни про формат ошибок, ни про соглашения. Она не требует, чтобы автор скилла кидал именно NotFound. Ей вообще всё равно, что там за ответ — важно только, что для несуществующего метода он один, а для существующего должен быть другой.
Проверку иногда нельзя провести — и это нормально
Теперь неприятный случай. Скилл, который на любой ввод отвечает одинаково. Неизвестный метод — "ok". Известный метод — "ok". Эталон совпадает со всем на свете.
Такой скилл невозможно проверить этим способом. Вообще. Контроль не даёт различимого сигнала, и сравнивать не с чем.
let unknown = call_execute(client, NONSENSE_METHOD).await; let Some(Verdict::Unknown) = unknown else { report.add( "implements the methods it declares", true, "skipped: the skill does not report unknown methods separately", ); return; };
Проверка помечается пропущенной, с текстом, объясняющим почему. Не «провалена» — потому что скилл ни в чём не уличён. И не «пройдена» молча — потому что тогда в отчёте будет зелёная галочка, за которой ничего не стоит, а это хуже, чем её отсутствие.
Мне кажется, это вообще главное, чему меня научила вся история: у проверки должно быть три исхода, а не два. «Годен», «не годен» и «здесь я вам ничего сказать не могу». Третий исход — единственная защита от галочки, которая выглядит как гарантия, но ей не является. У меня ведь именно так и получилось в первый раз: зелёная галочка, за которой пустота.
Где разрешено трогать
Дальше выяснилось, что у этой проверки есть цена, о которой я не подумал.
Смоук‑тест вызывает методы скилла. Настоящие, не понарошку. А методы — это код, написанный моделью полчаса назад и никем не читанный. send_message отправит сообщение. delete_old_files сделает ровно то, что написано. У проверки есть побочные эффекты, и заранее неизвестно какие — в этом вся суть ситуации.
Поэтому у валидатора появилась глубина:
Depth::Smoke— полная проверка, с вызовом заявленных методов.Depth::Contract— только то, что делается без дёрганья: жив ли процесс, отвечает ли на рукопожатие, не падает ли от мусора на входе. Методы не трогаются.
Смоук разрешён только внутри preflight‑контейнера, где скилл собирается и первый раз запускается. Там ему обрезан доступ к сети, ограничена память, и подпорчено всё, что он мог бы испортить — это временный контейнер, который через минуту исчезнет. Снаружи, на живой машине, валидатор до методов не дотрагивается.
Настолько не дотрагивается, что на это есть отдельный тест:
#[tokio::test] async fn the_contract_check_never_calls_the_skill_itself() { let report = report_for_liar(validator::Depth::Contract).await; assert!( check(&report, "implements the methods it declares").is_none(), "the contract depth called the skill's own methods" ); }
Тест не проверяет, что что‑то работает. Он проверяет, что чего‑то не произошло. Такие тесты я люблю больше всего — они охраняют решение, которое через полгода будет выглядеть как случайность, и первый же рефакторинг его снесёт.
И вот тут у меня наконец сложилось, зачем в этой схеме контейнер.
Я его ставил, думая про изоляцию — сдержать плохой код, если модель напишет что‑то дурное. Это неправда, и я это пишу прямо в документации проекта: если модель захочет сделать гадость, контейнер её не остановит, там сто способов выбраться, и я не пишу защиту от направленной атаки на собственную машину.
Контейнер нужен для другого. Он не тюрьма, он место, где можно потыкать палкой. Единственная площадка, где вызвать непроверенный метод — приемлемая цена за информацию.
Мелочи, которые вылезли по дороге
Пока всё это строилось, набралась россыпь отдельных граблей. Каждая на абзац, но пропустить их нельзя.
Имя модуля придумывает модель, а оно едет в путь файловой системы. То есть в один прекрасный день туда приезжает ../escape. На это есть тест, и называется он ровно так, как ощущается:
async fn a_bad_module_name_never_reaches_the_filesystem()
Простая мысль, которую легко забыть: имя, сгенерированное моделью — это пользовательский ввод. Со всеми вытекающими. То, что его «написал ваш же агент», ничего не меняет.
Код приезжает кусками. Ответ модели обрывается на лимите токенов, и скилл на четыреста строк в один ответ не помещается. Значит, приём по чанкам, буфер, сборка по последнему куску — и ни в коем случае не запись на диск раньше времени. Полуфайл, который выглядит как готовый — отдельный сорт удовольствия.
Вывод компилятора нужно вернуть модели, но не весь. cargo build на неудачной сборке выдаёт километр текста, из которого девяносто процентов — прогресс загрузки крейтов. Отдать это модели целиком значит забить контекст мусором ровно в тот момент, когда ей нужно думать.
let errors: Vec<&str> = stderr .lines() .skip_while(|line| !line.starts_with("error")) .take(60) .collect();
Мотаем до первого error, берём шестьдесят строк, режем по четырём тысячам символов. Если error не нашёлся вообще — отдаём хвост, тридцать последних строк, потому что что‑то отдать надо.
Попыток должно быть конечное число. Модель, которой вернули ошибку компиляции, будет чинить. Потом ещё раз. Потом ещё. Если её не остановить, она будет чинить, пока не упрётся во внешнюю стену — у меня это был недельный лимит бесплатного тарифа Ollama Cloud, сожжённый за вечер попытками починить один навык. Причём каждая следующая попытка не глупее предыдущей, она просто такая же: модель не помнит, что уже пробовала этот путь, если ей об этом не сказать.
Так что лимит на модуль, счётчик попыток — а при отказе счётчик обнуляется. Следующий заход через неделю начинается с чистого листа: за неделю могло измениться всё что угодно, вплоть до модели на том конце.
Отдельный сорт вранья: «сейчас посмотрю»
Уже сильно позже вылезла разновидность того же самого, до которой я долго не додумывался.
Ответ модели устроен так: текст для пользователя, флаг is_done и список действий. И иногда приходит ответ, в котором is_done: false, действий ноль, а в тексте — «одну минуту, проверяю».
Формально всё корректно. Схема соблюдена, JSON валиден, поле на месте. Агент послушно уходит на следующую итерацию — и получает то же самое. И ещё раз. Модель ведёт себя как человек, который на совещании говорит «да‑да, я этим занимаюсь», и на этом его участие заканчивается.
Молчаливая петля, которая жрёт итерации и токены, при этом на каждом ходу выглядит как нормальная работа.
Лечится прямой формулировкой в ответ:
const STALL_CORRECTION: &str = "Your last answer said is_done: false but sent no actions, so \ nothing actually ran — a message like \"one moment\" or \"checking now\" is not itself an \ action. Either send a real action (ExecuteModule, PromptToUser, SpawnAgent, ...) this turn, \ or set is_done: true if you are actually finished.";
Забавно, что это ровно та же ошибка, что и в самом начале статьи, только этажом выше. Там метод был объявлен и не реализован. Здесь работа объявлена и не сделана. В обоих случаях заявление приняли за факт, потому что оно было синтаксически безупречным
Что в итоге
Работающий путь от «я такого не умею» до установленного навыка занимает у меня сорок девять секунд — сборка, preflight в контейнере, валидация, установка. Большая часть из них — компиляция.
Из всего, что я тут понастроил, дороже всего оказалась не изоляция и не откат, а отрицательный контроль на девять строк. Потому что он ловит не сломанный код — сломанный код ловит компилятор. Он ловит код, который утверждает, что работает.
А это, как мне сейчас кажется, главный класс ошибок, который появляется, когда программы начинают писать программы. Раньше между заявлением и реализацией стоял человек, которому было неловко. Теперь не стоит никто.
Код открыт, ссылка. Написано на Rust, работает поверх любого OpenAI‑ или Anthropic‑совместимого эндпоинта — локальная Ollama, Ollama Cloud, свой роутер, провайдер напрямую.
Если у вас есть свой способ проверять код, который вы не писали — расскажите, будет интересно почитать.
Комментарии (6)

Ka463
26.08.2026 14:26Хмм не знаю как устроен ваш агент, но у меня к примеру само проверка работает, и всегда ловит ошибки, к тому же проверяет не только сами ошибки но и достигнута ли поставленная задача в полной мере, или агент написал какую то ерунду которая только делает вид что работает.

Valery247500
26.08.2026 14:26Отрицательный контроль на девять строк забираю целиком. Обидно только, что у себя я до того же дошёл не головой, а лбом.
У меня сканер состава продуктов, и там годами жила ровно ваша болезнь: я не знал, как у моей же системы выглядит «нет». Товара нет в базе — «нет данных». Штрихкод не прочитался — «нет данных». Товар непищевой и мы его в принципе не оцениваем — сюрприз, «нет данных». Три совершенно разные ситуации, один ответ на экране, и в логах их тоже не различить. Пользователь при этом честно считает, что приложение сломано.
Лечится это ровно вашим третьим исходом. «Не знаю такой товар», «не смог прочитать» и «такое я не оцениваю» — три разных ответа, и различать их должна не только система внутри, но и человек на экране. Зелёная галочка, за которой пустота, у меня выглядела как бодрое «нет данных» на туалетную бумагу.
Свой способ проверять код, который писал не я: канареечная запись прямо в данных. В базе лежит заведомо невозможный товар и ходит тем же путём, что и настоящие. Если он вдруг получил осмысленный вердикт — значит, кто-то по дороге заботливо подставил дефолт вместо честного «нет». Тот же forgotten, только этажом ниже, на уровне данных, а не методов.
А фраза про то, что раньше между заявлением и реализацией стоял человек, которому было неловко — лучшее, что я сегодня прочитал.

Wesha
26.08.2026 14:26там годами жила ровно ваша болезнь: я не знал, как у моей же системы выглядит «нет». Товара нет в базе — «нет данных». Штрихкод не прочитался — «нет данных». Товар непищевой и мы его в принципе не оцениваем — сюрприз, «нет данных». Три совершенно разные ситуации, один ответ на экране, и в логах их тоже не различить.
Так миллениалы узнали, что unhappy path тоже надо тестировать.

japo0nn Автор
26.08.2026 14:26Канарейка в данных — забираю, у меня этого слоя нет. Мой контроль ловит метод, который не подключён, но не ловит метод, который подключён и на любой ввод возвращает заглушку. То есть ровно ваш случай, только у меня он ещё впереди.
Причём я как раз выяснил, что «объявлен и не реализован» у меня чинится вообще без проверки — если в SDK объявление и реализация станут одним действием, эта ошибка перестанет быть выразимой. А ваша остаётся: метод зарегистрирован, вызывается, и внутри честная заглушка. Никакой типизацией это не ловится, только канарейкой в потоке данных.
Про три исхода на экране, а не только внутри — согласен, и это больнее, чем кажется. У меня «пропущено» видно в отчёте валидатора, а до пользователя доезжает та же зелёная строка, что и при «годен». Разделять надо не только внутри системы.
Wesha
Так миллениалы узнали о преимуществах стандартизации.
japo0nn Автор
Справедливо, я тут пошёл не с той стороны. Стандарт думал написать документацией — «всякий скилл обязан отвечать NotFound на неизвестный метод», — а её соблюдение всё равно пришлось бы проверять вызовом. Очередное заявление о себе, с которых статья и началась.
Правильная форма другая. У меня в SDK трейт с двумя независимыми членами: один возвращает список методов, второй их обрабатывает. Компилятор требует реализовать оба и никогда не сверит, что они про одно и то же. Если заменить это на регистрацию, где обработчик передаётся вместе с описанием, — объявить, не реализовав, станет нельзя. Не «ловится проверкой», а перестаёт быть выразимым.
Валидатор всё равно останется: протокол открытый, скилл можно написать без SDK на чём угодно. Но для своих проверка станет не нужна.
Так что не столько преимущества стандартизации, сколько преимущества стандарта, который нельзя нарушить.