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

Core Architecture

Архитектура ai_agent: агентный цикл со скиллами.

Цели

  • держать plugin.py тонким: только bootstrap и связывание QGIS
  • дать агенту возможность смотреть проект перед тем, как его менять
  • не раздувать промпт по мере роста числа доменов
  • добавлять новый домен файлами, а не правкой оркестрации

Package Layout

  • core/agent/ — агентный цикл: состояние прогона, сборка запроса, исполнение тулов, лента
  • core/orchestrator/ — связь UI с циклом и рендер хода в чат
  • core/llm/ — HTTP-клиент, транспортный адаптер, парсер
  • core/context/ — краткая стартовая сводка о проекте
  • core/state/ — окно истории для модели и сохранённые диалоги
  • qgis_tools/common/ — общее для доменов: слои, CRS, значения, сводка рендерера
  • qgis_tools/ — тулы в двенадцати доменах: inspect/, project/, style/, processing/, osm/, edit/, fields/, layout/, python/, web/, annotations/, three_d/
  • skills/ — пакеты знаний <skill>/SKILL.md и реестр

Runtime Flow

  1. plugin.py (корень сборки над слоями) поднимает AgentDockWidget и CoreOrchestrator.
  2. Оркестратор передаёт запрос пользователя в AgentLoop.start().
  3. Цикл собирает запрос (request.py) и отправляет ход в ModelTurnThread.
  4. Ответ приходит сигналом в главный поток — там исполняются локальные чтения, а записи и сетевые чтения попадают в очередь подтверждения.
  5. Цикл повторяется, пока модель вызывает тулы.
  6. Когда модель отвечает без вызовов, накопленные изменения уходят на подтверждение.

Модель потоков

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

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

Каждый тул объявляет safety:

Класс Поведение
read выполняется сразу, если не объявлен доступ к сети
write копится в батч, применяется после кнопки пользователя
destructive зарезервирован под персональное подтверждение

Write-вызов возвращает модели {"status": "queued"} — это штатный успешный ответ, а не ошибка. Изменения применяются только после нажатия кнопки.

Доступ к сети — отдельная от безопасности изменений возможность. Читающий тул с network_access = True ставится в очередь и автоматически приостанавливает цикл для явного подтверждения конкретного вызова. После одобрения результат попадает в ту же ленту, и цикл продолжается. Батч только из веб-вызовов не меняет QGIS и поэтому не делает снимок проекта; батч с записью по-прежнему делает.

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

Write исполняется после конца цикла, поэтому вернуть ошибку модели в этот момент уже некому. Всё, что можно проверить и нормализовать по аргументам, делает BaseTool.prepare при постановке в очередь — там цикл ещё жив, и агент успевает перестроить план. Хук возвращает исправленные аргументы, и в очередь попадают именно они: пользователь подтверждает ровно то, что выполнится.

Так устроена, например, проверка единиц CRS: запрос буфера в метрах на слое в EPSG:4326 отклоняется с готовым вызовом native:reprojectlayer в тексте ошибки, и агент сам достраивает перепроецирование.

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

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

Рост промпта по мере загрузки (пусто → inspect → +style → +processing): 2221 → 6942 → 9326 → 12012 символов. Без раскрытия каждый запрос стоил бы максимума.

Сверка с текущей практикой (проверено в августе 2026)

Формат и модель загрузки намеренно совпадают с Agent Skills — открытым стандартом (agentskills.io), принятым экосистемой: обязательные name и description во фронтматтере, Markdown-тело с инструкциями и трёхступенчатое прогрессивное раскрытие (метаданные при старте → тело при активации → ресурсы по запросу). Имена наших скиллов удовлетворяют правилам именования стандарта, а тела заметно короче рекомендованного предела в 500 строк. Единственное расширение — наш список tools во фронтматтере, связывающий скилл с классами тулов: спецификация допускает дополнительные поля, а фронтматтер мы парсим сами.

Два смежных веяния 2026 года рассмотрены и осознанно не взяты:

  • MCP. Model Context Protocol стандартизует доступ внешних агентов к живым системам. Наш плагин устроен наоборот: агент живёт внутри QGIS и ходит к модели по обычному REST. Выставить тулы QGIS как MCP-сервер — это другой продукт (QGIS для Claude Desktop и подобных), возможный будущий потребитель тех же скиллов, но не замена внутреннему циклу, которого требуют и вендор-нейтральность, и правило главного потока.
  • Скиллы поверх MCP. Полезно, когда одна организация раздаёт скиллы многим агентам из центрального реестра. У нас один агент и двенадцать скиллов в одном zip: транспортный слой между ними добавил бы зависимость и не убрал ничего.

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

run_python исполняет сниппет PyQGIS внутри работающего QGIS. Это постоянная часть архитектуры, а не временный костыль: агент должен доставать до всего API QGIS, а никакое разумное число тулов не покроет тысячу классов. Всё описанное ниже — осознанный выбор, «оптимизировать» его не нужно.

Почему destructive, а не анализ кода. Отличить читающий сниппет от пишущего статическим анализом нельзя — цепочки через getattr обходят любую проверку, а белый список по AST дал бы лишь ложное чувство защиты. Поэтому плагин не притворяется, что судит код. Подтверждением служит сам код: сниппет показывается пользователю в диалоге необратимых шагов до запуска, а поле intent — одна человеческая фраза — обязательно.

Почему находка bandit подавлена на строке, а не в конфиге. B102: exec_used — верная находка, а не ложное срабатывание. Внесение её в skips файла .bandit выключило бы проверку по всему пакету и спрятало от ревьюера самое рискованное свойство кода. #nosec B102 стоит там, где риск. Это единственная уступка сканеру во всём пакете: конфига bandit нет вовсе — ни в репозитории, ни в zip; и каталог, и CI сканируют штатными правилами и получают чистый отчёт. Намеренно проглоченные исключения записаны как contextlib.suppress(...), а не пустым except: pass — намерение выражено конструкцией, а не тишиной, которую сканер обязан подсветить. tests/test_publish_ready.py держит это честным: exec встречается ровно в одном файле, подавление ровно одно, и тул, который его использует, обязан быть класса destructive.

Почему нет доступа к шеллу. Всё, что умеет QGIS, достижимо из Python. Системный шелл расширил бы поверхность риска, не расширив того, что агент способен сделать в терминах ГИС.

Под подтверждением лежат две страховки: бюджет строк через sys.settrace не даёт зациклившемуся сниппету подвесить QGIS, а компиляция при постановке в очередь отклоняет синтаксическую ошибку, пока цикл ещё жив и может её починить.

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

Транспорт

Плагин не привязан к вендору: он ходит на тот адрес, который пользователь указал в настройках. Роль «хаба ИИ» выполняет сама настройка URL, отдельного слоя для этого не нужно. Достаточно, чтобы эндпоинт понимал формат OpenAI Chat Completions — так умеют почти все корпоративные шлюзы. Пользователь задаёт URL, ключ (хранится в базе учётных данных QGIS, не в конфиге) и имя модели.

core/llm/transport.py пробует нативный function calling (tools + tool_choice). Если эндпоинт отвечает ошибкой про неподдерживаемый параметр, адаптер переключается на JSON-протокол в промпте и запоминает выбор в QgsSettings по хешу URL. Оба пути нормализуются в один ModelTurn — цикл не знает, какой сработал.

Если появится эндпоинт с принципиально другим форматом, ему нужен свой адаптер рядом с transport.py, а не правки в цикле.

Сетевой стек QGIS

Обычные одиночные запросы к модели используют QgsBlockingNetworkRequest. Стриминг и веб-инструменты — два осознанных исключения: оба используют QgsNetworkAccessManager с вложенным QEventLoop. Стримингу нужно читать тело по мере поступления. Веб-чтению тот же доступ к ответу нужен, чтобы остановиться на лимите размера до буферизации всего тела, быстро обработать отмену и проверить каждый редирект до перехода. Веб-клиент асинхронно разрешает DNS средствами Qt, отклоняет хост, если хотя бы один ответ не является глобально маршрутизируемым, и закрепляет один проверенный IP на каждую попытку прямого подключения. TLS по-прежнему проверяет исходное имя хоста (setPeerVerifyName), а HTTP/1.1 передаёт его в Host; попытки через неразрешённое имя нет.

Редиректы обрабатываются вручную: допускается не больше трёх переходов в рамках того же origin, причём сырое значение Location проверяется до следующего запроса. Проверенный IP остаётся закреплённым; при ошибке соединения пробуется следующий адрес из уже проверенного набора DNS. Когда QGIS выбирает явный прокси для одобренного имени, запрос сохраняет это имя, чтобы соблюсти маршрутизацию и DNS-политику прокси. Прямой запрос остаётся закреплённым, а несовпадение маршрута для формы с IP блокирует его. Остановка прерывает и DNS-поиск, и ответ, а проверка поколения не даёт запустить резервный поиск после остановки. Загрузка и сохранение cookie, а также повторное использование кешированной HTTP-аутентификации выключены: хосты на одном CDN-адресе не могут разделить состояние через закреплённый IP. Для вызывающего операция остаётся блокирующей, все пути остаются в сетевом стеке QGIS, а веб-запрос начинается только после отдельного подтверждения.

Стриминг

Ответ приходит по словам, а не появляется целиком после долгой паузы. Это одно из мест, где QgsBlockingNetworkRequest не годится: он отдаёт готовый ответ, и прочитать тело по мере поступления нечем. Поэтому путь стриминга идёт через QgsNetworkAccessManager с вложенным QEventLoop — со стороны вызывающего запрос по-прежнему блокирующий, так что он остаётся в том же фоновом потоке, а цикл над ним не меняется. Настройки прокси и аутентификации QGIS соблюдаются в обоих случаях — оба класса стоят на одном сетевом стеке.

Разбор намеренно разделён надвое. llm/stream.py — чистый Python: он собирает Server-Sent Events через границы чанков и сворачивает дельты в обычную форму ответа, поэтому _parse_native_turn одинаково разбирает и потоковый ответ, и обычный. llm/stream_runner.py держит всё, что связано с Qt и сетью. Тесты нужны только первой половине — она их и получает.

Стриминг подчиняется тому же правилу feature-detect, что tools и картинки: его пробуют, а эндпоинт, который отказал, записывается как supports_streaming = false по хешу URL и больше не спрашивается. Неудачная попытка проваливается в обычный запрос, так что сервер без SSE теряет живой текст и больше ничего.

Отказ приходится отличать от сбоя, иначе возможность выключает себя из-за постороннего: опечатка в ключе отвечает 401, занятый провайдер — 429, и ни то ни другое ничего не говорит о стриминге. Отказом считаются только статусы неподдержанного параметра с подходящим маркером в теле; всё остальное поднимается той ошибкой, какой является. Сервер, который игнорирует stream и отвечает одним обычным JSON, ловится тем же способом: поток не даёт ни одного события, и это считается отказом, а не пустым ответом.

Живой текст уходит в интерфейс только до первой дельты с вызовом тула, но сказанное моделью перед вызовом не выбрасывается. Когда ход приходит целиком, цикл объявляет преамбулу, и оркестратор сохраняет её ровно как финальный ответ — лента читается так, как модель работала: строка текста, коробка действий, снова текст. Чат и сохранённая переписка остаются равны — это и есть инвариант, который важен.

Стриминг на обоих диалектах

Диалекты оформляют поток по-разному, поэтому у каждого своя свёртка, а работа с сокетом общая. OpenAI шлёт одну форму choices[].delta; Anthropic — по типизированному событию на шаг: content_block_start, затем text_delta, thinking_delta, signature_delta или input_json_delta, затем content_block_stop, и завершает message_stop, а не маркером [DONE]. anthropic_stream.py сворачивает эти события ровно в ту форму ответа, которую уже читают parse_response и parse_thinking, — ниже по течению никто не узнаёт, что ответ пришёл по частям.

Аргументы тулов приходят как partial_json, разрезанный между событиями, и разбираются только при закрытии блока. Поэтому отказ у Anthropic приходится различать аккуратно: сообщение про thinking поднимается наверх, чтобы вызывающий повторил без него, и только настоящая жалоба на стриминг выключает стриминг. Перепутать порядок — значит выключить не ту возможность.

Думающие модели

Рассуждение приходит в трёх разных формах, и обработаны все три — эндпоинт плагин себе не выбирает.

Локальные серверы — Ollama, llama.cpp, LM Studio — кладут рассуждение прямо в content, обёрнутым в теги <think>. Если его не трогать, оно становится видимым ответом, попадает в транскрипт и уезжает обратно модели на каждом следующем шаге. llm/thinking.py вырезает его маленькой машиной состояний — она нужна потому, что тег может разорваться между чанками: <thi в одном, nk> в следующем. Вырезание идёт до того, как JSON-протокол что-то разберёт: модель, рассуждающая о выборе тула, пишет объекты-кандидаты, и парсер иначе волен предпочесть один из них настоящему ответу.

DeepSeek и OpenRouter шлют отдельное поле reasoning_content (или reasoning). Оно не касается content, так что ничего не ломается, — но без чтения дельт панель стояла бы неподвижно всю минуту, что модель думает, ровно тогда, когда признак жизни нужнее всего.

Anthropic возвращает типизированные блоки thinking с полем signature. Это единственный вид рассуждения, который обязан уехать обратно: при расширенном рассуждении вместе с тулами API отвергает ход ассистента, где эти блоки пропали или переставлены. Поэтому ModelTurn несёт сырые блоки, транскрипт хранит их рядом с ходом, а _assistant_message ставит их обратно первыми, впереди текста и вызовов. Расширенное рассуждение выключено, пока в настройках не задан бюджет, — оно стоит токенов, — а эндпоинт, отказавший в параметре, запоминается, как и любая другая неподдержанная возможность.

Текст рассуждения не уходит обратно ни на один эндпоинт и не попадает в сохранённую переписку. В чате он живёт в своём сворачиваемом блоке: раскрыт, пока растёт, и сворачивается в одну строку, когда пошёл ответ.

Добавление нового домена

  1. qgis_tools/<domain>/ — классы тулов с skill = "<domain>" и safety
  2. skills/<domain>/SKILL.md — фронтматтер и правила домена
  3. подключить список тулов в qgis_tools/registry.py

Оркестрация, цикл и промпт не трогаются. Домены не зависят друг от друга: общее берётся из qgis_tools/common/, поэтому домен можно удалить, не ломая соседние.

Диалоги

Диалог живёт дольше сеанса QGIS. ConversationState держит две вещи сразу:

  • HistoryStore — короткое окно (WINDOW_LIMIT сообщений), которое уезжает модели
  • Session — вся переписка целиком, она же попадает на диск

Одно сообщение добавляется одним вызовом add(role, text) — расходиться этим двум хранилищам нельзя. Сохранение идёт сразу после каждого сообщения, поэтому падение QGIS не съедает диалог.

SessionStore кладёт по одному JSON на диалог в ai_agent_sessions/ внутри профиля QGIS. Диалог привязан к проекту: recent() отдаёт только те, что начаты в открытом сейчас проекте, а несохранённый проект получает общую корзину «без проекта». Заголовок берётся из первой реплики пользователя.

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

Сохраняется переписка, а не ход прогона: вызовы тулов и карточки плана при восстановлении не возвращаются. Зато исход применения записывается как реплика агента — иначе модель следующим ходом не знает, легли её изменения или нет (confirm_pending завершает прогон и не отдаёт результат обратно в цикл).

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

История

Печатные макеты были отдельным доменом до версии 0.2.0 и удалены: их предстоит переделать. Работающая реализация сохранена в теге v0.1.0-diploma.

Prompt Policy

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