Core architecture¶
How the agent inside ai_agent works and why it is built this way.
Package layout¶
plugin.pyis the composition root: it builds the dock widget and the orchestrator and wires them to QGIS.core/agent/holds the loop: run state, request assembly, tool execution, the transcript, Apply and the check after it.core/orchestrator/connects the UI to the loop and renders a run into the chat.core/llm/is the HTTP client, the two API dialects and the parsers.core/state/keeps the model's window and the saved conversations.qgis_tools/<domain>/holds the tools, one class per file;qgis_tools/common/holds code shared across domains.skills/<domain>/SKILL.mdholds the rules of each domain.
No agent framework¶
The loop is our own code. Agent frameworks do not fit a QGIS plugin:
- Each one is a dependency, and the plugin runs in the QGIS Python, where every extra package is an installation problem for the user. Today the plugin has none.
- Most are built around one vendor's SDK. The plugin must work with any OpenAI-compatible endpoint and with local servers.
- They run the loop on a background thread or with
asyncio. PyQGIS objects may be touched only from the main thread, so such a loop crashes QGIS. - The loop itself is small. What it needs beyond a framework is QGIS-specific: a batch the user applies, a project snapshot, a check after Apply.
The same reasons rule out exposing the tools over MCP instead: the agent lives inside QGIS and talks to a model over plain REST.
The loop¶
The loop works act, observe, decide. It is not a one-shot planner.
- The orchestrator hands the request to
AgentLoop.start(). - The loop assembles the request (
request.py) and sends it fromModelTurnThread. - The reply arrives on the main thread as a signal. Local reads run at once and their results go back to the model. Writes and network reads join a batch.
- The loop repeats while the model calls tools.
- A run ends when the model replies without tool calls. The batch then goes to the user.
A step limit and a token budget from the settings stop a runaway run with a plain explanation.
Safety classes¶
Every tool declares safety:
readruns at once, unless it declaresnetwork_access.writejoins a batch that runs after the user presses Apply.destructivealso joins the batch and asks for a separate confirmation of each call.
A write call returns {"status": "queued"} to the model. That is success, not
an error.
A read with network_access = True is queued too, and the run pauses for the
user to approve that exact call. Its result then enters the transcript and the
run resumes. A batch of network reads only does not take a project snapshot,
because it changes nothing in QGIS.
Auto mode presses Apply for the user (agent/auto_apply.py). A batch
applies itself only when no call in it is destructive or unknown, and never
after a failed run. It then takes the same path as the button: snapshot,
destructive check, verification.
Plan mode offers read tools only. The dispatcher refuses any other call. The run ends with a numbered plan and an offer to carry it out in Ask or Auto mode.
Validation before the queue¶
A write runs after the loop has ended, when nobody is left to read an error.
So BaseTool.prepare checks and normalises the arguments when the call is
queued, while the model can still fix them. The corrected arguments go into
the queue, so the user confirms exactly what will run. For example, a buffer
in metres on a layer in degrees is refused with a ready native:reprojectlayer
call in the error, and the model adds that step itself.
Snapshots and the check after Apply¶
Every applied batch with a write takes a project snapshot first. Undo on the
plan card and rewinding to an earlier message restore these snapshots
(orchestrator/rewind.py).
QGIS 4 writes every .qgz through one fixed file in the system temp folder,
qgis-project-XXXXXX.zip, then renames it into place. Two QGIS processes
saving at the same moment share that file: one save fails, or one project file
ends up holding the other process's project. A snapshot therefore checks that
the archive it wrote holds its own project file and writes it again if not
(project/snapshots.py). The real-QGIS tests give every QGIS process its own
temp folder, so parallel runs never meet in that file.
After Apply the orchestrator checks the result. First it reads: each write
tool may answer confirm_applied with the one read that proves its step.
When every step of a successful batch reads back as done, no model run starts.
Otherwise a verification run starts: the model re-reads the result, looks at a
rendered image for visual changes and queues fixes for what the request asked
for and did not get. A tool that cannot be sure returns None from
confirm_applied; a false "done" is the one failure this check must not have.
Main thread and background tasks¶
PyQGIS and Qt objects may be touched only from the main thread. So the loop is
a state machine on the main thread. The HTTP request runs in ModelTurnThread;
_on_turn and tool execution run as main-thread slots.
A long step would freeze QGIS for as long as it runs. So a tool may hand its
heavy part to a QGIS task (BaseTool.start, qgis_tools/common/background.py):
- a Processing algorithm through
QgsProcessingAlgRunnerTask, as the Processing toolbox does for "Run in background"; - a download through
qgis_tools/common/network.py, which waits in the task's own thread.
The task touches only thread-safe objects. The project, layers and widgets are
touched when it ends, back on the main thread. The apply run
(agent/apply_run.py) returns to the Qt event loop while the task works and
continues on its signal; Stop cancels the task and the steps after it.
Algorithms flagged NoThreading still run on the main thread.
Never move a whole tool or the loop to a background thread, and never rewrite
the loop as a background while or with asyncio.
Network calls off the main thread¶
PyQt and the GIL set traps for a worker thread that talks HTTP, and
qgis_tools/common/network.py::send is the one place that knows them:
- A
QgsBlockingNetworkRequestmade from Python can freeze QGIS for good when it runs off the main thread. When it finishes, QGIS destroys its private event loop while holding a Qt lock and asks Python whether the request object, the feedback passed with it or a Python-made application object overridesdisconnectNotify; the question needs the GIL. A main thread that holds the GIL and waits for the same lock (anyconnect, such as a style sheet on a new widget) never gets it. sip remembers a "no" per object, sosendasks once itself, with the GIL held and no Qt lock taken, before the request starts; the request itself then runs without the GIL. Running the request onQgsNetworkAccessManagerfrom Python instead only turns the deadlock around: the worker then holds the GIL while Qt takes its locks, and a main thread deleting an object connected to a Python-made widget takes a lock first and the GIL second. - A stream needs the body as it arrives, so
sendruns it onQgsNetworkAccessManagerin the thread's own event loop, which keeps that rarer reversed risk; every Python worker that emits signals has it too. - PyQt 6 reports the deletion of a QObject made in C++ to the main thread, by
address. A report about a reply or network manager deleted in a worker can
arrive after the address was reused, and then marks a newer, live object as
deleted: the next model turn failed before its request was even sent.
sendcuts that report on the manager and the reply of a stream.
Skills and progressive disclosure¶
A skill is a domain package: SKILL.md with frontmatter (name,
description, tools) and a body of rules. The system prompt always holds one
line per skill. The body and the tool schemas load when the model calls
load_skill. inspect is always loaded, so a simple question costs no extra
turn. Without this, every request would carry every schema, and the prompt
would grow with each new domain.
The format follows the open Agent Skills standard (agentskills.io): name and
description frontmatter, a Markdown body, loading on demand. The tools list
is our extension; it binds a skill to its tool classes.
Domain rules live only in SKILL.md. core/agent/prompts.py holds
domain-independent behaviour only.
The Python escape hatch¶
run_python runs a PyQGIS snippet inside QGIS. It is a permanent part of the
design. The agent should reach the whole QGIS API, and no number of tools
covers thousands of classes.
- It is
destructiverather than analysed. Static analysis cannot tell a reading snippet from a writing one, so the plugin does not pretend to. The user sees the code in the confirmation dialog before anything runs, with a required one-sentenceintent. - The bandit finding
B102: exec_usedis real. It is suppressed with#nosec B102on the oneexecline, not in a config file, so the check stays on for the rest of the package.tests/test_publish_ready.pykeepsexecin one file, with one suppression, in a destructive tool. - There is no shell tool. Everything QGIS can do is reachable from Python; a shell would only widen the attack surface.
- A line budget (
sys.settrace) stops a runaway loop, and the snippet is compiled when queued, so a syntax error comes back while the loop can fix it.
skills/python/SKILL.md tells the model to try a real tool first. A snippet
that works points at a missing tool.
Transport¶
The plugin talks to whatever address the user sets. The endpoint needs to speak OpenAI Chat Completions or the Anthropic Messages API; there are no provider SDKs.
core/llm/transport.py tries native function calling first. If the endpoint
rejects the tools parameter, it switches to a JSON-in-prompt protocol and
remembers that for the endpoint. Both paths produce the same ModelTurn.
Streaming, images and reasoning follow the same rule: try, and remember a
refusal per endpoint. A refusal is told apart from an outage, so a 401 or 429
never switches a feature off.
All HTTP stays on the QGIS network stack, so QGIS proxy and authentication
settings apply. Ordinary calls use QgsBlockingNetworkRequest and streaming
uses QgsNetworkAccessManager with a nested event loop, both through
qgis_tools/common/network.py (see "Network calls off the main thread").
The web tools run their own QgsNetworkAccessManager loop on the main
thread, because they need the body while it arrives. They also accept only
public DNS answers, pin a checked IP while TLS verifies the original host name, follow
only same-origin redirects they have checked, pass the host name to an
explicit QGIS proxy and fail closed when a direct route does not match.
Reasoning¶
Reasoning arrives in three shapes, and all are handled:
- Local servers put it in
contentinside<think>tags.llm/thinking.pycuts it out, also when a tag is split across chunks. - DeepSeek and OpenRouter send a separate
reasoning_contentfield. - Anthropic returns signed
thinkingblocks. With tools in the same run the API requires them back, soModelTurncarries them and they are re-sent first in the assistant message.
Otherwise reasoning is shown in the chat and never sent back, with one
exception: DeepSeek in thinking mode answers 400 unless every earlier
assistant message carries its reasoning_content, so with the Reasoning
switch on it is echoed there. The saved conversation keeps reasoning for the
chat only.
Some models reason only when asked, and there is no standard way to ask. The
Reasoning switch (off by default) becomes the parameter the host expects
(llm/reasoning.py): thinking for DeepSeek, reasoning.effort for
OpenRouter and reasoning_effort for every other host. An unknown host gets
reasoning_effort because most compatible servers copied it; a server that
rejects it costs one retry, and the refusal is remembered as
supports_thinking = false. This check runs before the streaming and tools
checks, so a complaint about reasoning_effort never turns streaming off.
Conversations¶
ConversationState wraps one Session: the full transcript plus the summary
left by the last compaction. The model's window is the summary and every
message after it. The conversation is saved after every message as JSON in
ai_agent_sessions/ in the QGIS profile, bound to the open project. Saving
the project under a new name keeps the conversation, its run and its
snapshots: QGIS renames the project without clearing it, so it is the same
project. Opening another one starts a new conversation. The chat
also saves what it showed of each turn (steps, reasoning, plan cards); none of
that reaches the model. Switching conversations is refused while a run or an
unapplied batch is active.
Prompt policy¶
- System prompts,
SKILL.mdand tool schemas are English. - The reply language comes from
language_policy(locale): the QGIS interface language goes into the prompt, and the model switches to the user's language when they write in another one. - Interface text is an English original inside
tr()plus a compiled.qm.