Thread contract

August 2, 2026 · View on GitHub

Every LLMQore object — BaseClient and its subclasses, ToolsManager, ToolRegistry, ToolHandler, McpClient, McpServer, AcpClient — lives on the thread of the QObject parent passed to its constructor.

  • All public methods must be called from that thread.
  • All signals are emitted on that thread.

Debug builds enforce the first rule with Q_ASSERT_X on the public mutating and raw-pointer-returning methods of those classes. A release build will not warn you; it will race.

The one exception is BaseClient::cancelRequest(). It marshals itself onto the client's thread, so it is safe to call from anywhere — cancelling from a UI thread while the client runs on a worker is a supported pattern.

Consuming from another thread

Connect with Qt::AutoConnection (the default). Qt queues the delivery and copies the arguments, so a widget or model living on the GUI thread can observe a client running on a worker thread without extra locking.

connect(client, &LLMQore::BaseClient::chunkReceived,
        this, [this](const LLMQore::RequestID &, const QString &chunk) {
    m_view->append(chunk);
});

Everything crossing a signal is a value type — Conversation, TurnContent, ToolResult, CompletionInfo, ModelInfo. Copying one is safe from any thread; none of them hand out a pointer into library-owned storage.

The one pointer rule

ToolRegistry::registeredTools() returns QList<BaseTool *> — raw pointers owned by the registry. They are valid only until the next event-loop iteration, because removing a tool deletes it.

For anything that outlives the immediate call — a UI list, a snapshot passed to another thread — use toolsSnapshot(), which returns detached ToolSnapshot values:

for (const auto &snapshot : client->tools()->toolsSnapshot())
    ui->addRow(snapshot.displayName, snapshot.description);

Content blocks are values

Before 0.8.0, BaseMessage handed out ContentBlock * into a list that reallocated on every append, so holding one across a parse step was a use-after-free waiting to happen. Content is now a std::variant stored by value: currentBlocks() returns const QList<TurnContent> &, and currentToolUseContent() / currentThinkingContent() return copies. There is no pointer to outlive.

currentBlocks() still hands back a reference into the message's own storage, which the next tool round clears and which dies with the message. Copy it before it crosses an event-loop iteration or a thread boundary — the same rule registeredTools() follows above. The two current*Content() accessors already return detached values, so they need no such care.