Перейти к содержанию

Архитектура ядра

Как устроен агент внутри ai_agent и почему именно так.

Устройство пакета

  • plugin.py — корень сборки: создаёт панель и оркестратор и связывает их с QGIS.
  • core/agent/ — цикл: состояние прогона, сборка запроса, исполнение инструментов, лента, применение и проверка после него.
  • core/orchestrator/ связывает интерфейс с циклом и выводит прогон в чат.
  • core/llm/ — HTTP-клиент, два диалекта API и разбор ответов.
  • core/state/ хранит окно модели и сохранённые разговоры.
  • qgis_tools/<domain>/ — инструменты, по одному классу на файл; qgis_tools/common/ — общий для доменов код.
  • skills/<domain>/SKILL.md — правила каждого домена.

Без агентного фреймворка

Цикл — наш собственный код. Агентные фреймворки не подходят плагину QGIS:

  • Каждый из них — зависимость, а плагин работает в Python из QGIS, где любой лишний пакет — проблема установки для пользователя. Сейчас у плагина зависимостей нет.
  • Большинство построены вокруг SDK одного вендора. Плагин же должен работать с любым OpenAI-совместимым эндпоинтом и с локальными серверами.
  • Они крутят цикл в фоновом потоке или через asyncio. Объекты PyQGIS можно трогать только из главного потока, поэтому такой цикл роняет QGIS.
  • Сам цикл небольшой. Всё, что ему нужно сверх фреймворка, специфично для QGIS: пакет изменений, который применяет пользователь, снимок проекта, проверка после применения.

По тем же причинам инструменты не отдаются наружу через MCP: агент живёт внутри QGIS и говорит с моделью по обычному REST.

Цикл

Цикл работает по схеме «действие → наблюдение → решение». Это не планировщик в один проход.

  1. Оркестратор передаёт запрос в AgentLoop.start().
  2. Цикл собирает запрос (request.py) и отправляет его из ModelTurnThread.
  3. Ответ приходит в главный поток сигналом. Локальные чтения выполняются сразу, их результаты возвращаются модели. Записи и сетевые чтения попадают в пакет.
  4. Цикл повторяется, пока модель вызывает инструменты.
  5. Прогон заканчивается, когда модель отвечает без вызовов. Тогда пакет уходит пользователю.

Лимит шагов и бюджет токенов из настроек останавливают затянувшийся прогон с понятным объяснением.

Классы безопасности

Каждый инструмент объявляет safety:

  • read выполняется сразу, если не объявляет network_access.
  • write попадает в пакет, который выполняется после нажатия «Применить».
  • destructive тоже попадает в пакет и требует отдельного подтверждения каждого вызова.

Вызов записи возвращает модели {"status": "queued"}. Это успех, а не ошибка.

Чтение с network_access = True тоже ставится в очередь, и прогон приостанавливается, пока пользователь не одобрит именно этот вызов. Затем его результат попадает в ленту, и прогон продолжается. Пакет только из сетевых чтений не делает снимок проекта, потому что ничего в QGIS не меняет.

Режим «Авто» нажимает «Применить» за пользователя (agent/auto_apply.py). Пакет применяется сам, только если в нём нет опасных или неизвестных вызовов, и никогда после неудачного прогона. Дальше он идёт тем же путём, что и по кнопке: снимок, проверка опасных шагов, проверка результата.

Режим «План» предлагает только читающие инструменты. Диспетчер отклоняет любой другой вызов. Прогон заканчивается нумерованным планом и предложением выполнить его в режиме «Спрашивать» или «Авто».

Проверка до очереди

Запись выполняется после конца цикла, когда прочитать ошибку уже некому. Поэтому BaseTool.prepare проверяет и нормализует аргументы при постановке в очередь, пока модель ещё может их исправить. В очередь идут исправленные аргументы, так что пользователь подтверждает ровно то, что выполнится. Например, буфер в метрах на слое в градусах отклоняется с готовым вызовом native:reprojectlayer в тексте ошибки, и модель сама добавляет этот шаг.

Снимки и проверка после применения

Каждый применённый пакет с записью сначала делает снимок проекта. Отмена на карточке плана и откат к прежнему сообщению восстанавливают эти снимки (orchestrator/rewind.py).

QGIS 4 пишет каждый .qgz через один и тот же файл во временной папке системы, qgis-project-XXXXXX.zip, и потом переименовывает его на место. Два процесса QGIS, сохраняющие проект в один момент, делят этот файл: одно сохранение не удаётся, или файл одного проекта оказывается с проектом другого процесса. Поэтому снимок проверяет, что записанный архив содержит его собственный файл проекта, и иначе пишет его заново (project/snapshots.py). Тесты в настоящем QGIS дают каждому процессу QGIS свою временную папку, так что параллельные прогоны в этом файле не встречаются.

После применения оркестратор проверяет результат. Сначала чтением: каждый пишущий инструмент может ответить в confirm_applied одним чтением, которое доказывает его шаг. Если каждый шаг успешного пакета читается как выполненный, прогон модели не начинается. Иначе запускается проверочный прогон: модель перечитывает результат, смотрит на отрисовку при визуальных изменениях и ставит исправления для того, что просили, но не получили. Инструмент, который не уверен, возвращает из confirm_applied None: ложное «готово» — единственная ошибка, которой у этой проверки быть не должно.

Главный поток и фоновые задачи

Объекты PyQGIS и Qt можно трогать только из главного потока. Поэтому цикл — машина состояний в главном потоке. HTTP-запрос выполняется в ModelTurnThread; _on_turn и исполнение инструментов — слоты главного потока.

Долгий шаг заморозил бы QGIS на всё время работы. Поэтому инструмент может отдать тяжёлую часть задаче QGIS (BaseTool.start, qgis_tools/common/background.py):

  • алгоритм Processing через QgsProcessingAlgRunnerTask, как делает панель Processing при запуске в фоне;
  • загрузку через qgis_tools/common/network.py, которая ждёт в собственном потоке задачи.

Задача трогает только потокобезопасные объекты. Проект, слои и виджеты трогаются, когда она закончилась, снова в главном потоке. Прогон применения (agent/apply_run.py) возвращается в цикл событий Qt, пока задача работает, и продолжает по её сигналу; «Стоп» отменяет задачу и шаги после неё. Алгоритмы с флагом NoThreading по-прежнему идут в главном потоке.

Никогда не переносите в фоновый поток инструмент целиком или цикл и не переписывайте цикл как фоновый while или на asyncio.

Сетевые вызовы не из главного потока

PyQt и GIL расставляют ловушки рабочему потоку, который ходит по HTTP, и знает о них одно место — qgis_tools/common/network.py::send:

  • QgsBlockingNetworkRequest, созданный из Python, может навсегда заморозить QGIS, если работает не в главном потоке. Завершаясь, QGIS уничтожает его собственный цикл событий, держа блокировку Qt, и спрашивает Python, не переопределён ли disconnectNotify у объекта запроса, у переданного с ним feedback или у созданного в Python объекта приложения; для вопроса нужен GIL. Главный поток, который держит GIL и ждёт ту же блокировку (любой connect, например таблица стилей на новом виджете), её не дождётся. sip запоминает ответ «нет» для каждого объекта, поэтому send сам спрашивает один раз, держа GIL и не держа блокировок Qt, ещё до запроса; сам запрос потом идёт без GIL. Запрос через QgsNetworkAccessManager из Python лишь переворачивает взаимоблокировку: тогда рабочий поток держит GIL, пока Qt берёт свои блокировки, а главный поток, удаляя объект, связанный с виджетом из Python, берёт сначала блокировку, а потом GIL.
  • Потоковому ответу тело нужно по мере прихода, поэтому send ведёт его через QgsNetworkAccessManager в собственном цикле событий потока, и этот более редкий обратный риск остаётся; он есть у любого рабочего потока на Python, который испускает сигналы.
  • PyQt 6 сообщает главному потоку об удалении объекта QObject, созданного в C++, по адресу. Сообщение об ответе или сетевом менеджере, удалённом в рабочем потоке, может прийти, когда адрес уже занят снова, и тогда помечает удалённым новый, живой объект: следующий ход модели падал, не успев отправить запрос. send обрывает это сообщение у менеджера и ответа потокового запроса.

Навыки и постепенное раскрытие

Навык — пакет домена: SKILL.md с фронтматтером (name, description, tools) и правилами в теле. Системный промпт всегда держит по одной строке на навык. Тело и схемы инструментов загружаются, когда модель вызывает load_skill. inspect загружен всегда, поэтому простой вопрос не стоит лишнего хода. Без этого каждый запрос нёс бы все схемы, а промпт рос бы с каждым новым доменом.

Формат следует открытому стандарту Agent Skills (agentskills.io): фронтматтер с name и description, тело на Markdown, загрузка по требованию. Список tools — наше расширение; он привязывает навык к его классам инструментов.

Правила доменов живут только в SKILL.md. В core/agent/prompts.py — только поведение, не зависящее от домена.

Запасной ход на Python

run_python выполняет сниппет PyQGIS внутри QGIS. Это постоянная часть устройства. Агент должен дотягиваться до всего API QGIS, а никакое число инструментов не покроет тысячи классов.

  • Он destructive, а не анализируется. Статический анализ не отличит читающий сниппет от пишущего, поэтому плагин и не делает вид, что может. Пользователь видит код в диалоге подтверждения до запуска, вместе с обязательным intent в одно предложение.
  • Находка bandit B102: exec_used настоящая. Она подавлена через #nosec B102 на единственной строке с exec, а не в конфиге, так что для остального пакета проверка остаётся. tests/test_publish_ready.py следит, чтобы exec был в одном файле, с одним подавлением, в опасном инструменте.
  • Инструмента для оболочки нет. Всё, что умеет QGIS, доступно из Python; оболочка только расширила бы поверхность атаки.
  • Бюджет строк (sys.settrace) останавливает бесконечный цикл, а сниппет компилируется при постановке в очередь, так что синтаксическая ошибка возвращается, пока цикл может её исправить.

skills/python/SKILL.md велит модели сначала пробовать настоящий инструмент. Сработавший сниппет указывает на недостающий инструмент.

Транспорт

Плагин говорит с тем адресом, который задал пользователь. Эндпоинт должен понимать OpenAI Chat Completions или Anthropic Messages API; SDK провайдеров нет.

core/llm/transport.py сначала пробует нативный вызов функций. Если эндпоинт отклоняет параметр tools, адаптер переходит на протокол JSON в промпте и запоминает это для эндпоинта. Оба пути дают одинаковый ModelTurn. Потоковый ответ, картинки и рассуждение следуют тому же правилу: попробовать и запомнить отказ для эндпоинта. Отказ отличается от сбоя, поэтому 401 или 429 никогда не выключает возможность.

Весь HTTP идёт через сетевой стек QGIS, так что действуют настройки прокси и авторизации QGIS. Обычные вызовы используют QgsBlockingNetworkRequest, а потоковый ответ — QgsNetworkAccessManager с вложенным циклом событий, оба через qgis_tools/common/network.py (см. «Сетевые вызовы не из главного потока»). Веб-инструменты крутят свой цикл QgsNetworkAccessManager в главном потоке, потому что им нужно тело по мере прихода. Они к тому же принимают только публичные ответы DNS, закрепляют проверенный IP, пока TLS проверяет исходное имя хоста, следуют только проверенным редиректам в пределах origin, передают имя хоста явному прокси QGIS и блокируют запрос, если прямой маршрут не совпал.

Рассуждение

Рассуждение приходит в трёх видах, и все три обрабатываются:

  • Локальные серверы кладут его в content внутри тегов <think>. llm/thinking.py вырезает его, даже когда тег разрезан между кусками.
  • DeepSeek и OpenRouter присылают отдельное поле reasoning_content.
  • Anthropic возвращает подписанные блоки thinking. Если в том же прогоне есть инструменты, API требует их обратно, поэтому ModelTurn несёт их, и они отправляются первыми в сообщении ассистента.

В остальном рассуждение показывается в чате и обратно не отправляется, с одним исключением: DeepSeek в режиме размышления отвечает 400, если у каждого прежнего сообщения ассистента нет reasoning_content, поэтому при включённом переключателе «Рассуждение» оно отправляется туда обратно. Сохранённый разговор держит рассуждение только для чата.

Некоторые модели рассуждают, только если их попросить, и стандартного способа попросить нет. Переключатель Рассуждение (по умолчанию выключен) превращается в параметр, которого ждёт хост (llm/reasoning.py): thinking для DeepSeek, reasoning.effort для OpenRouter и reasoning_effort для любого другого хоста. Незнакомый хост получает reasoning_effort, потому что большинство совместимых серверов его скопировали; сервер, который его отклоняет, стоит одного повтора, и отказ запоминается как supports_thinking = false. Эта проверка идёт раньше проверок потокового ответа и инструментов, так что жалоба на reasoning_effort никогда не выключает поток.

Разговоры

ConversationState оборачивает одну Session: полную ленту и сводку от последнего сжатия. Окно модели — это сводка и все сообщения после неё. Разговор сохраняется после каждого сообщения в JSON в ai_agent_sessions/ в профиле QGIS и привязан к открытому проекту. Сохранение проекта под новым именем оставляет разговор, его прогон и снимки: QGIS переименовывает проект, не очищая его, значит, это тот же проект. Открытие другого проекта начинает новый разговор. Чат сохраняет и то, что показал по каждому ходу (шаги, рассуждение, карточки плана); ничего из этого не доходит до модели. Переключить разговор нельзя, пока идёт прогон или ждёт неприменённый пакет.

Политика промптов

  • Системные промпты, SKILL.md и схемы инструментов — на английском.
  • Язык ответа задаёт language_policy(locale): язык интерфейса QGIS идёт в промпт, а модель переходит на язык пользователя, когда он пишет на другом.
  • Текст интерфейса — английский оригинал в tr() плюс скомпилированный .qm.