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

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

Но для выполнения сложных заданий, сохранения состояния между сессиями и управления самостоятельными решениями агента этого оказалось недостаточно. Так я пришёл к разработке Site Agent Runtime — отдельной многокомпонентной системы для управления автономными ИИ-агентами. MODX стал отправной точкой, но большинство возникающих проблем от конкретной CMS не зависит. Те же вопросы появляются при работе с репозиториями, серверной инфраструктурой, базами данных и другими внешними системами.

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

Давайте сначала попробуем

Чтобы не ограничиваться рассказом о том, как всё замечательно задумано, я подготовил открытый репозиторий с исходниками и автоматическими тестами трёх компонентов Runtime: Task Manager, ChangeSet и State Guard.

→ Исходный код и тесты на GitHub

Есть и интерактивная демонстрация. Можно выбрать один из компонентов, последовательно выполнить три действия и посмотреть, какие операции система разрешает, а какие отклоняет. Устанавливать ничего не нужно: настоящий Python-код выполняется непосредственно в браузере через Pyodide.

→ Попробовать интерактивную демонстрацию

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

Почему решение агента ещё не повод менять сайт

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

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

Поэтому в Site Agent Runtime я разделил принятие решений, подготовку плана и исполнение. Соответствующая часть архитектуры выглядит так:

Это только фрагмент общей архитектуры Runtime
Это только фрагмент общей архитектуры Runtime

TaskSpec формализует задание и допустимые действия. Resolver сопоставляет их с конкретными объектами сайта, DecisionSet фиксирует технические решения. ChangeSet превращает их в проверяемый план, который затем может быть передан отдельному Executor.

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

ChangeSet представляет собой декларативный документ с описанием изменений, исходного состояния объектов, зависимостей, рисков, условий проверки результата, резервного копирования и отката. Но основная работа заключается не в сохранении JSON.

Метод bindscope() связывает каждую операцию с конкретной целью, определённой Resolver, и проверяет её тип, статус и допустимое действие, а также соответствие идентификатора исходному объекту, если он указан в операции. Если разрешено обновить ресурс, а агент предлагает его удалить, операция отклоняется. То же происходит при попытке изменить посторонний объект.

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

При этом ChangeSet не пытается повторно исследовать всю CMS. Он работает с результатами предыдущих компонентов и отвечает за собственные инварианты. Иначе довольно быстро получилось бы несколько независимых механизмов, каждый из которых пытается самостоятельно разобраться во всём. Знакомая история: хотели разделить ответственность, а получили несколько универсальных компонентов вместо одного.

Две успешные записи и одно потерянное изменение

При реализации ChangeSet обнаружилась проблема, уже совершенно не связанная с искусственным интеллектом. Обычная конкурентная запись, причём с довольно любопытным поведением.

Для хранения документов я выбрал JSON-файлы. При небольшом количестве задач это простой вариант, не требующий отдельной СУБД. Запись выполняется через временный файл с flush, fsync и последующим os.replace(), а для синхронизации используется fcntl.flock.

Казалось бы, всё предусмотрено: файлы заменяются атомарно, запись защищена блокировкой. Но давайте посмотрим, что произойдёт, если два процесса почти одновременно прочитают одну редакцию ChangeSet.

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

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

Потеря изменений при конкурентной записи и обнаружение конфликта
Потеря изменений при конкурентной записи и обнаружение конфликта

Это классический lost update. Атомарная замена защищает от частичной записи файла, но не гарантирует, что новая редакция подготовлена на основании актуального состояния. Блокировка тоже не помогает, если защищает только сохранение уже подготовленного документа.

Что особенно интересно, файловая система честно выполнила всё, что от неё требовалось. Проблема оказалась в том, что требовал я недостаточно.

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

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

with self._locked():
current = self._read_existing(task_id)
if current != existing:
raise ChangeSetError(
"Concurrent ChangeSet modification; reload and retry"
)
self._atomic_json(self._path(task_id), document)

Если другой процесс успел изменить документ, операция отклоняется. Вместо молчаливой перезаписи получаем явный конфликт.

Но здесь обнаружилась ещё одна тонкость. У ChangeSet есть номер редакции revision. Казалось бы, можно сравнивать только его, а не весь документ. Зачем усложнять? Оказалось, нельзя.

Операция seal() переводит план из состояния draft в sealed, фиксируя его содержимое. При этом номер редакции не обязательно увеличивается. Следовательно, процесс со старой копией draft может не заметить фиксацию и перезаписать документ, который уже нельзя изменять обычным способом.

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

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

Как проверить ошибку, которая возникает не всегда

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

Поэтому в test_competing_writers_cannot_lose_revision я использовал threading.Barrier. Два потока читают одну исходную версию ChangeSet, после чего барьер удерживает их до момента, когда оба достигнут нужной точки. Затем они продолжают выполнение и пытаются сохранить собственные изменения.

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

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

Отдельный test_concurrent_seal_cannot_be_overwritten_by_stale_draft моделирует ситуацию, когда документ успели зафиксировать между первоначальным чтением и попыткой записи. Устаревший draft не должен перезаписывать sealed, даже если номера редакций совпадают.

Таким образом, тесты проверяют не просто корректность отдельных методов, а конкретные инварианты конкурентного состояния. Исходники доступны в репозитории, а автоматические проверки запускаются в GitHub Actions на Linux с Python 3.10–3.13.

А если агент передумал?

Следующий вопрос касается уже зафиксированного плана. Пока ChangeSet находится в состоянии draft, его можно редактировать. Но после вызова seal() обычный set() больше не позволяет изменить содержимое. Иначе теряется смысл фиксации: проверяли один план, а выполнять собираемся другой.

Для внесения поправок предусмотрен отдельный метод amend(). Он сохраняет историю поправок и изменения состава операций, увеличивает редакцию и возвращает документ в draft. При этом сбрасывается прежнее подтверждение: если пользователь согласовал изменение трёх ресурсов, добавление четвёртого не должно автоматически наследовать его разрешение.

Целостность плана контролируется с помощью канонического SHA-256. Перед фиксацией ChangeSet под блокировкой проверяет, совпадает ли фактическое содержимое черновика с сохранённым отпечатком. Для зафиксированного документа отпечаток также пересчитывается при последующих проверках состояния. Если данные изменились, а контрольные значения остались прежними, компонент обнаружит расхождение.

Это проверяется, в частности, тестом test_frozen_payload_recalculated, который специально изменяет содержимое зафиксированного документа. После подмены ChangeSet должен признать план неготовым к исполнению.

Но контроль целостности и проверка полномочий — совершенно разные задачи. SHA-256 позволяет выявлять нарушения целостности, но не устанавливает, кто именно изменил документ.

Предположим, агент подготовил операцию высокого риска и вызывает approve(source="user"). Выглядит убедительно, только source — обычная строка, которую агент способен передать самостоятельно. По одному этому параметру невозможно установить, действительно ли пользователь что-то разрешил.

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

Поэтому в опубликованном ChangeSet метод approve() намеренно отклоняет любые вызовы. Независимого доверенного механизма подтверждения здесь нет, а изображать его наличие с помощью строкового аргумента мне показалось плохой идеей. Планы, требующие согласования, нельзя зафиксировать и через seal() в обход этого ограничения.

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

Когда правильный план становится неправильным

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

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

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

ChangeSet использует read_set и контрольные отпечатки исходных данных. Кроме того, он учитывает маркеры устаревшего состояния, сохраняемые State Guard. Однако State Guard не следит за изменениями сайта самостоятельно, а успешная проверка ещё не исключает изменения объекта за мгновение до записи.

Это классическая TOCTOU-гонка — time-of-check to time-of-use. Чтобы устранить такой промежуток, проверка ожидаемого состояния должна быть атомарно связана с самой операцией изменения, например через compare-and-swap или эквивалентную условную запись.

Сложность в том, что разные CMS и другие внешние системы предоставляют для этого разные возможности. Универсальный контракт исполнения ещё предстоит разработать, и приписывать ChangeSet гарантии, которые должен обеспечивать Executor, было бы неправильно.

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

Что в результате

ChangeSet — только одна часть значительно более крупной системы. Для полноценной автономной работы необходимы управление заданиями, сохранение состояния, контроль полномочий, исполнение операций и обработка сбоев. Site Agent Runtime продолжает разрабатываться, и далеко не на все архитектурные вопросы пока найдены окончательные ответы.

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

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