Архитектура ядра¶
Как устроен агент внутри 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.
Цикл¶
Цикл работает по схеме «действие → наблюдение → решение». Это не планировщик в один проход.
- Оркестратор передаёт запрос в
AgentLoop.start(). - Цикл собирает запрос (
request.py) и отправляет его изModelTurnThread. - Ответ приходит в главный поток сигналом. Локальные чтения выполняются сразу, их результаты возвращаются модели. Записи и сетевые чтения попадают в пакет.
- Цикл повторяется, пока модель вызывает инструменты.
- Прогон заканчивается, когда модель отвечает без вызовов. Тогда пакет уходит пользователю.
Лимит шагов и бюджет токенов из настроек останавливают затянувшийся прогон с понятным объяснением.
Классы безопасности¶
Каждый инструмент объявляет 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.