28 KiB
QodeAssist — Target Architecture (v1.0)
Status: design baseline, derived from the fixed use-case inventory below.
Scope: the complete plugin, designed "from scratch" — what the architecture
should be if nothing legacy constrained it. The current code (see
architecture.md) already converges on this; §10 lists the remaining deltas.
1. Use-case inventory (requirements baseline)
Every architectural decision below is justified by one of these. Features not on this list (Rules system, legacy provider/model/template pickers, Stack A) are intentionally out of scope.
| # | Use case | What the user gets |
|---|---|---|
| U1 | Code completion | Inline FIM/instruct suggestions via LSP; auto + manual trigger, multiline, smart-context suppression, accept full / word-by-word |
| U2 | Chat assistant | 4 placements (sidebar, bottom pane, editor tab, floating window); streaming text + thinking blocks + tool blocks + file-edit blocks (apply/undo); attachments, linked files, @-mentions, open-files sync; token counter; persisted history; one-click summarization; runtime agent + role pickers |
| U3 | Quick refactor | Selection + instruction by hotkey; custom-instructions library; separate agent; optional tools; streamed result inserted into the editor |
| U4 | Tools | read/create/edit file, search, find, list, build, diagnostics, terminal, todo, load_skill; per-tool enable |
| U5 | Skills | discovery from .qodeassist/skills, .claude/skills, ~/.claude/skills; auto-injection, explicit / picker, always-on |
| U6 | MCP | server mode (expose plugin tools, HTTP/SSE + stdio bridge) and client hub (consume external tools in chat/refactor) |
| U7 | Providers | 13 client_api types over one GenericProvider; secrets store; local-server autostart; model listing |
| U8 | Agents | TOML profiles: extends, [body] table 1:1 with the wire request, Jinja partials, match rules, per-agent model override, per-pipeline rosters |
| U9 | Roles | JSON roles composed into system_prompt via {{ agent_role(id) }} |
| U10 | Bench CLI | headless agent testing on the same core stack, .env secrets |
| U11 | Configuration UI | settings pages for everything above; per-project settings; updater + status widget |
2. Design principles
- One stack. Every LLM byte — completion, chat, compression, refactor,
bench — flows through the same
Sessionpipeline. No parallel legacy path. - Hexagonal core. The runtime (agents, sessions, providers, templates, prompt rendering) has zero Qt Creator dependencies. The IDE and the bench CLI are two hosts composing the same core; IDE-specific facts enter only through ports (document reading, project scanning, secrets, tool hosting).
- Configuration is declarative, code is mechanism. What is sent (request
[body], system prompt, endpoint, model) lives in TOML/JSON/Jinja and is user-overridable; how it is sent (streaming, retries, tool loop, event routing) lives in C++ and is identical for all providers. - Capability-driven behavior. Providers and agents declare capabilities (tools, thinking, images, model listing); features and UI adapt to the declared set instead of switching on provider names.
- Single source of truth for conversation state.
ConversationHistoryowns the messages;ChatModeland persistence are projections of it, never independent copies. - Per-feature composition roots, no singletons. Each feature constructs
and owns its dependencies (
new+ parent); shared services are passed explicitly (constructor/setter, QML context properties for the chat). - Streaming-first event model. One typed
ResponseEventstream is the only contract between the core and every consumer. Deltas exist for live UI (chat); one-shot pipelines (completion, refactor, bench) ignore them, wait forfinished, and read the final assistant message from history. - Fail at load, not mid-conversation. Agent profiles are validated when loaded (partials resolve, assembled body parses as JSON against a synthetic context), so a config error never surfaces as a silent runtime drop.
3. Layered model
flowchart TB
subgraph HOSTS["Hosts — composition roots"]
PLUGIN["Qt Creator plugin<br/>qodeassist.cpp"]
BENCH["bench CLI"]
end
subgraph L5["L5 · Presentation"]
LSP["LSP bridge<br/>inline suggestions"]
QMLUI["ChatView QML<br/>4 placements"]
RW["Refactor widgets"]
SUI["Settings pages"]
end
subgraph L4["L4 · Features"]
FCOMP["CompletionFeature"]
FCHAT["ChatFeature"]
FREF["RefactorFeature"]
end
subgraph L3["L3 · Capabilities"]
CTX["ContextEngine<br/>ports + QtC adapters"]
TOOLS["ToolKit"]
SKILLS["SkillsEngine"]
MCPH["McpHub<br/>client + server"]
end
subgraph L2["L2 · Core runtime — IDE-independent"]
SM["SessionManager"]
SESS["Session"]
AGF["AgentFactory + AgentRouter"]
AG["Agent"]
PROV["GenericProvider"]
TPL["JsonPromptTemplate"]
end
subgraph L1["L1 · Declarative config"]
PCONF["providers/*.toml"]
ACONF["agents/*.toml + partials/*.jinja"]
ROST["rosters / pipelines"]
ROLES["agent_roles/*.json"]
SKCONF["skills/*.md"]
SEC["SecretsStore"]
end
subgraph L0["L0 · Wire — LLMQore"]
CLIENTS["*Client — SSE streaming"]
TOOLFW["Tool framework"]
MCPT["MCP transports"]
end
PLUGIN --> L4
PLUGIN --> SUI
BENCH --> SM
LSP --> FCOMP
QMLUI --> FCHAT
RW --> FREF
FCOMP --> SM
FCHAT --> SM
FREF --> SM
FCOMP --> CTX
FCHAT --> CTX
FREF --> CTX
FCHAT --> SKILLS
FCHAT --> TOOLS
FREF --> TOOLS
TOOLS --> TOOLFW
MCPH --> MCPT
SM --> SESS
SESS --> AG
AGF --> AG
AG --> PROV
AG --> TPL
AGF --> ACONF
AGF --> PCONF
AGF --> SEC
AGF --> ROST
TPL --> ROLES
PROV --> CLIENTS
SKILLS --> SKCONF
Layer contracts
| Layer | Contains | May depend on | Must NOT depend on |
|---|---|---|---|
| L0 Wire | LLMQore clients (one per wire protocol: Claude, OpenAI Chat, OpenAI Responses, Google, Ollama, Mistral, llama.cpp), tool framework, MCP transports | Qt Network | anything above |
| L1 Config | ProviderInstance, AgentProfile (+ loader/validator), rosters, roles, skills, secrets port |
toml++, inja | Qt Creator, L2+ |
| L2 Core | Agent, AgentFactory, AgentRouter, Provider/GenericProvider, JsonPromptTemplate, Session, SessionManager, ConversationHistory, SystemPromptBuilder, ResponseRouter, ToolContributorRegistry |
L0, L1 | Qt Creator, QML, features |
| L3 Capabilities | ContextEngine (ports + QtC adapters), ToolKit (built-in tools), SkillsEngine, McpHub |
L0–L2, QtC APIs only in adapters | features, UI |
| L4 Features | CompletionFeature, ChatFeature (send/stream, compression, token counting, file edits), RefactorFeature |
L2, L3 | each other |
| L5 Presentation | LSP bridge, ChatView QML, refactor widgets, settings pages | its feature | core internals |
| Hosts | plugin shell, bench CLI | everything (composition only) | — |
The hard rule that makes U10 (bench) and testability free: L0–L2 build into targets with no Qt Creator linkage. Bench links L0–L2 plus a thin CLI host; the plugin adds L3 adapters, L4, L5.
4. Core domain model
Rendered copy: core-class-diagram.svg (regenerate when the diagram below changes).
classDiagram
direction TB
class SessionManager {
+acquire(agentName) Session
+release(session)
+toolContributors() ToolContributorRegistry
}
class Session {
+send(blocks, toolPolicy)
+cancel()
+history() ConversationHistory
+systemPrompt() SystemPromptBuilder
+event(ResponseEvent)
+finished(id, stopReason)
+failed(id, ErrorInfo)
+cancelled(id)
}
class ConversationHistory {
+messages() vector~Message~
+lastAssistantText() string
+append(Message)
+reset(vector~Message~)
}
class Message {
+role Role
+blocks vector~ContentBlock~
}
class SystemPromptBuilder {
+setLayer(id, text, priority)
+removeLayer(id)
+compose() string
}
class ResponseRouter {
+attach(BaseClient)
+event(ResponseEvent)
}
class Agent {
+config() AgentConfig
+provider() Provider
+promptTemplate() PromptTemplate
}
class AgentFactory {
+create(name) Agent
+configByName(name) AgentConfig
+effectiveModel(name) string
}
class AgentRouter {
+pickAgent(roster, fileCtx) string
}
class Provider {
<<interface>>
+capabilities() Capabilities
+prepareRequest(request, ctx)
+sendRequest(json) RequestID
+cancelRequest(RequestID)
}
class GenericProvider {
-client BaseClient
}
class PromptTemplate {
<<interface>>
+buildFullRequest(request, ctx)
}
class JsonPromptTemplate {
-bodySpec QJsonObject
-env InjaEnvironment
}
class ToolContributorRegistry {
+registerContributor(fn)
+applyTo(ToolsManager)
}
SessionManager o-- Session : pools
SessionManager --> AgentFactory : builds via
SessionManager --> ToolContributorRegistry
Session *-- ConversationHistory
Session *-- SystemPromptBuilder
Session *-- ResponseRouter
Session --> Agent
ConversationHistory o-- Message
Agent *-- Provider
Agent *-- PromptTemplate
AgentFactory ..> Agent : creates
AgentFactory --> AgentRouter
GenericProvider --|> Provider
JsonPromptTemplate --|> PromptTemplate
Responsibilities, one line each:
- Agent — immutable bundle of what to call: resolved config + provider + compiled prompt template. No request state.
- Session — one conversation's runtime: owns history, system-prompt
layers, response routing, the in-flight request, and the tool-execution
loop (tool_use → execute → tool_result → continue).
send(blocks)is the only entry point: every pipeline appends a user message and dispatches; there are no per-pipeline send variants. What differs between completion, chat, and refactor is the agent's template and the consumption mode (deltas vs final message), never the Session API. - SessionManager — creates/pools sessions per agent; the single place
features go to get one. Pooling (not per-message construction) covers the
"fresh agent + provider + secrets read per request" latency cost. It reuses
only the expensive parts (agent, provider, compiled template, secrets read):
acquirehands out a session with cleared history and system-prompt layers, so one-shot pipelines never see a previous exchange. - AgentRouter — the only agent picker. Every pipeline (completion, chat,
compression, refactor) resolves its agent through
pickAgent(roster, {file, project}); no feature-local picker logic. - GenericProvider — one class for all 13 client APIs; varies only by LLMQore client factory + metadata. Request shape belongs to the template, never to the provider.
- JsonPromptTemplate — compiles the agent's
[body]table; renders Jinja-bearing string values, splices raw JSON, drops empty keys; validated at load time. - SystemPromptBuilder — ordered named layers (
agent.system,chat.context,refactor,compression); features mutate only their own layer. - ResponseRouter / ResponseEvent — adapts LLMQore client signals into one
typed stream:
TextDelta,ThinkingDelta,ToolCallStart/End,ToolResult,Usage,Error,MessageStop. - ToolContributorRegistry — contributors (built-in ToolKit, SkillTool,
McpHub) register once;
SessionManagerapplies them to every new session'sToolsManager. This is how MCP tools reach chat and refactor (U6) without feature code knowing about MCP.
5. Runtime flows
5.1 Chat (U2) — the richest path
sequenceDiagram
autonumber
actor U as User
participant V as ChatView QML
participant F as ChatFeature
participant SM as SessionManager
participant S as Session
participant T as JsonPromptTemplate
participant P as GenericProvider
participant C as LLMQore Client
participant R as ResponseRouter
U->>V: message + attachments
V->>F: sendMessage(text, files, images)
F->>SM: acquire(activeAgent)
SM-->>F: Session (pooled)
F->>S: systemPrompt().setLayer("chat.context", project + skills + linked files)
F->>S: send(userBlocks, toolPolicy)
S->>T: buildFullRequest(history, system, ctx)
T-->>S: request JSON (body is 1:1 with the API)
S->>P: sendRequest(json)
P->>C: HTTP POST, SSE stream
loop streaming
C-->>R: chunk / thinking / tool_use / usage
R-->>S: ResponseEvent
S-->>F: event(ResponseEvent)
F-->>V: ChatModel projection update
end
opt tool call requested
S->>S: execute tool via ToolsManager
S->>P: continue with tool_result
end
C-->>R: finalized
R-->>S: MessageStop + Usage
S-->>F: finished()
F->>SM: release(session)
State ownership in chat: Session.history() is the truth. ChatModel is a
QML projection built from history events (messageAdded, messageUpdated);
ChatSerializer/ChatHistoryStore persist history, and restoring a chat
seeds a new session's history — never the other way around. File-edit blocks,
apply/undo, and the token counter are ChatFeature concerns layered on the
event stream.
5.2 Completion (U1)
LSP getCompletionsCycling
→ CompletionFeature
agent = AgentRouter.pickAgent(roster.codeCompletion, {file, project})
session = SessionManager.acquire(agent)
ctx = ContextEngine: prefix/suffix + open-files context (policy from
CodeCompletionSettings — editor policy, not agent config)
session.send(blocks{completion context}, tools=off)
on finished → history().lastAssistantText()
→ CodeHandler (output-mode post-processing) → LSP items
No special Session method: the completion context travels as the content of
an ordinary user message (a structured block carrying prefix/suffix + file
context), and the template context exposes it as ctx.prefix / ctx.suffix.
FIM vs instruct is agent config (template + body), not feature code: a FIM
agent's body renders prefix/suffix into FIM fields; an instruct agent's
body renders the same exchange as a chat-shaped request. The feature is
identical for both — and since completion has no incremental UI, it never
touches the delta stream: it waits for finished and reads the last message.
5.3 Quick refactor (U3)
Hotkey → RefactorFeature
agent = AgentRouter.pickAgent(roster.quickRefactor, {file, project})
session = SessionManager.acquire(agent)
session.systemPrompt().setLayer("refactor", tagged selection + output rules)
session.send(blocks{instruction}, toolPolicy)
on finished → history().lastAssistantText()
→ ResponseCleaner → RefactorResult → editor insert (accept/reject)
Same consumption mode as completion: the feature listens to
Session::finished/failed only (events at most drive a progress spinner
and cancel) and reads the result from history — it never connects to raw
client signals. Tool calls during refactor run inside the session's tool
loop; history's last assistant message is whatever the model produced after
the final tool round.
5.4 Compression (U2) and bench (U10)
Compression is ChatFeature reusing the same path with
roster.chatCompression and a "compression" system layer; the summary
starts a new history. Bench is a host: CLI args + .env secrets → L1 + L2
composition → Session.send → events printed to stdout. Anything bench can't
do without the IDE is, by construction, an L3+ concern.
6. Configuration model
erDiagram
AGENT_PROFILE ||--o| AGENT_PROFILE : extends
AGENT_PROFILE }o--|| PROVIDER_INSTANCE : provider_instance
AGENT_PROFILE }o--o{ PARTIAL : includes
AGENT_PROFILE }o--o{ ROLE : agent_role
ROSTER }o--o{ AGENT_PROFILE : ranks
MODEL_OVERRIDE |o--|| AGENT_PROFILE : overrides_model
PROVIDER_INSTANCE }o--|| CLIENT_API : client_api
PROVIDER_INSTANCE }o--o| SECRET : api_key_ref
PROVIDER_INSTANCE ||--o| LAUNCH_CONFIG : autostarts
AGENT_PROFILE {
string name
bool abstract
string system_prompt "jinja, composes agent_role()"
json body "request body, 1:1 with API"
string endpoint "may contain MODEL placeholder"
string model "default; override wins"
bool enable_tools "capability hint"
bool enable_thinking "capability hint"
json match "file, path, project patterns"
}
PROVIDER_INSTANCE {
string name
string client_api
string url
string api_key_ref
}
ROLE {
string id
string systemPrompt
}
ROSTER {
string pipeline "completion, chat, compression, refactor"
list agents "ordered candidates"
}
Rules of the config layer (full spec: agent-templates-design.md):
[body]is the request body — field-by-field, deep-mergeable throughextends; Jinja-bearing strings render and splice as raw JSON, literals pass through. No separate sampling/thinking merge machinery.includeresolves only sandboxed partial roots (bundled:/agents/partials/, then userpartials/); a missing partial is a load-time error.- Two-level hierarchy: one abstract base per provider family, thin children.
- Per-agent model override lives in
agent_models.jsonand is applied byAgentFactory;${MODEL}inendpointcovers URL-model providers. - Roles are JSON managed by the Roles settings UI; profiles pull them in with
{{ agent_role("<id>") }}— the only system-prompt edit point is the profile. - Secrets never appear in TOML;
api_key_refresolves through theSecretsStoreport (QtC keychain in the plugin,.envin bench).
7. Capabilities layer
ContextEngine replaces the monolithic ContextManager with three focused services behind IDE-agnostic ports:
| Service | Port (L2-visible) | QtC adapter |
|---|---|---|
EditorContext — current doc, selection, prefix/suffix |
IDocumentReader |
TextEditor API |
ProjectContext — root, file listing, ignore filtering (.qodeassistignore), open files, changes |
IProjectScanner |
ProjectExplorer API |
TokenEstimator — input estimates, calibrated by server usage |
— (pure) | — |
ToolKit registers the built-in tools (U4) with the
ToolContributorRegistry; each tool declares a permission class (read /
write / execute) so per-tool enablement (settings) and confirmation policy
(terminal commands) live in one place.
SkillsEngine (U5): discovery + watching of the three skill roots; exposes
catalogText() (names + descriptions for the system prompt),
alwaysOnBodies(), and the load_skill tool; the / picker injects a
skill's body into a single message.
McpHub (U6): client side connects configured servers and contributes their tools through the same registry (tools reach every session uniformly); server side exposes ToolKit over HTTP/SSE + stdio bridge.
8. Cross-cutting policies
Architecture is the rules as much as the boxes. These policies bind every layer and are part of the contract:
8.1 Threading
The core runs on the GUI thread; concurrency is the Qt event loop plus async network I/O — no shared-state threading anywhere in L1–L4. Work that can block (project scans, token estimation over large trees) hides behind L3 ports; an adapter may use worker threads internally but delivers results as queued signals. Core types are therefore deliberately not thread-safe.
8.2 Request lifecycle
A session has at most one in-flight request; send() while in flight cancels
the previous request first. Every request terminates in exactly one of three
states — finished(stopReason), failed(error), cancelled() — and
cancellation is not an error: no consumer may string-match a message to
tell them apart.
8.3 Errors
Runtime errors are typed, not strings: ErrorInfo { category, message, providerDetail } with categories Config | Auth | Network | Provider | Validation | Tool. The category drives UI affordances (Auth → open provider
settings, Network → offer retry); free text is for logs only. Load-time
errors (principle 8) surface in the agents settings page, never as a failed
send.
8.4 Timeouts and retries
Transfer timeouts are per-pipeline policy (completion short, chat/refactor from settings), applied by the feature — never baked into agent profiles. A streaming request is never silently retried after the first byte; automatic retry with capped backoff is allowed only for connection-phase failures. Anything beyond that is an explicit user action.
8.5 Observability
One RequestID correlates feature → session → provider → client → events →
logs. Each layer logs under its own category (qodeassist.session,
qodeassist.provider, qodeassist.tools, …); request bodies are logged only
at debug level, and secrets are redacted unconditionally. Usage events are
the single source feeding the token counter, TokenEstimator calibration,
and the performance log.
8.6 Config compatibility
Agent profiles carry a schema_version; the loader migrates old user
configs forward or rejects them with an actionable message — silent
reinterpretation is forbidden. Bundled profiles are read-only resources that
user profiles shadow by name. Persisted chat history is versioned the same
way.
8.7 Security
Secrets exist only behind the SecretsStore port; they never reach TOML,
logs, or persisted chats. Tool permission classes (read / write / execute)
centralize the confirmation policy. The MCP server is opt-in and binds
loopback by default; skill and partial roots are sandboxed — nothing resolves
outside its declared directory.
8.8 Testing
The test pyramid follows the layers:
| Layer | Strategy |
|---|---|
| L1 | loader/validator unit tests; golden-file snapshots of every bundled profile's rendered body against a synthetic context — the same check as load-time validation, run in CI |
| L2 | Session / ResponseRouter replay tests over recorded SSE fixtures per provider; fake BaseClient, no network |
| L3 | contract tests against the ports; QtC adapters covered only by plugin integration |
| E2E | bench (U10) against live providers — the same composition the plugin uses |
Layering is enforced mechanically, not by review: each layer is its own CMake target, and the core targets do not link Qt Creator — a violating include fails the build.
9. Module / target layout
core/ # no Qt Creator linkage — bench + tests link this
config/ # L1: ProviderInstance, AgentProfile, loaders,
# validators, rosters, roles, secrets port
providers/ # L2: Provider, GenericProvider, ProviderFactory,
# ClaudeCacheControl
prompt/ # L2: JsonPromptTemplate, ContextRenderer, partials
agents/ # L2: Agent, AgentFactory, AgentRouter
session/ # L2: Session, SessionManager, ConversationHistory,
# SystemPromptBuilder, ResponseRouter, events
skills/ # L3 (IDE-free part): SkillsEngine, loaders
ide/ # Qt Creator adapters only
context/ # EditorContext, ProjectContext adapters, ignore
tools/ # built-in ToolKit (build, issues, editor edits…)
mcp/ # McpHub managers
features/
completion/ # LSP bridge + CompletionFeature + CodeHandler
chat/ # ChatFeature: ClientInterface, ChatModel(projection),
# Compressor, TokenCounter, FileEditController,
# serializer/store
refactor/ # RefactorFeature + custom instructions
ui/
ChatView qml/, widgets/, settings pages
hosts/
plugin/ # qodeassist.cpp — composition root, actions, panes
bench/ # CLI composition root
tests/
config/ # loader cases + golden rendered-body snapshots
session/ # SSE replay fixtures per provider, fake client
external/
llmqore/ inja/ tomlplusplus/
Dependency direction is strictly downward in the table of §3; features/*
never include each other; ui/* talks only to its feature; hosts/* are the
only places allowed to know about everything.
10. Deltas from the current working tree
What "from scratch" changes relative to today's code — the migration checklist to call the architecture done:
- Stack A physical teardown — delete root
providers/*,pluginllmcore/*,ConfigurationManager, legacy provider/model/template settings pages, and the Stack A registration + MCP loop inqodeassist.cpp. Runtime already has no consumers. - Single history owner — make
ChatModela projection ofSession::history()(subscribe to history signals) instead of a parallel message store with seed-on-send;ChatCompressorreads history, not the model. - Single send path — delete
Session::sendCompletion(ContextData); the completion context becomes user-message content sent through the onesend()(the completion handler already reads its result from history's last message). MoveQuickRefactorHandleroff rawBaseClientsignals (requestCompleted/requestFinalized/requestFailed) ontoSession::finished/failed+history().lastAssistantText(). - Three-state request lifecycle — add
cancelledtoSession; todaycancel()emitsfailed(id, "Cancelled by user")and consumers must string-match to tell cancellation from failure (§8.2). - Typed errors — replace
lastErrorstrings and thefailed(QString)payload withErrorInfocategories (§8.3). - One agent picker — fold
pickCompletionAgent/pickRefactorAgentremnants intoAgentRouter.pickAgent(roster, …)exclusively; chat picker filters to thechatAssistantroster. - MCP tools on session clients — register MCP-contributed tools through
ToolContributorRegistryso chat/refactor sessions get them (today they are registered only on dead Stack A providers). - Session pooling —
SessionManager.acquire/releasewith a small pool per agent, replacing per-message agent + provider + secrets construction. - ContextManager split — extract
EditorContext/ProjectContext/TokenEstimatorbehind ports; move QtC API use intoide/context. [body]model completion — finishagent-templates-design.md(body-table rendering, sandboxedinclude, load-time validation, model override +${MODEL},schema_versiongate), delete sampling/thinking merge machinery.- Message type unification — one
Message/ContentBlockshape from history to QML (roles, text, thinking, tool use/result, images); delete the parallelChatModel::Messagestruct. - Test scaffolding — golden rendered-body snapshots + SSE replay fixtures (§8.8); CI builds the core targets without Qt Creator so a layering violation fails the build.
- Stale docs cleanup —
project-rules.mddescribes the removed Rules system; mark or delete.