From 2aa748b14ad9d58e94977dfb42292a4bfcf2aa99 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:04:45 +0200 Subject: [PATCH] refactor: final Agent loader --- README.md | 7 +- docs/agent-templates-design.md | 33 +- docs/architecture.md | 9 +- docs/creating-agents.md | 339 ++++++++++++++++++ sources/agents/AgentConfig.hpp | 1 - sources/agents/AgentFactory.cpp | 1 + sources/agents/AgentLoader.cpp | 100 +++++- sources/agents/AgentRouter.cpp | 26 +- sources/agents/agents.qrc | 11 +- sources/agents/claude_sonnet_chat.toml | 14 - sources/agents/codestral_base_chat.toml | 11 + sources/agents/codestral_chat.toml | 8 +- sources/agents/codestral_fim.toml | 14 +- sources/agents/codestral_fim_base.toml | 17 + sources/agents/llamacpp_base_chat.toml | 11 + sources/agents/llamacpp_chat.toml | 8 +- sources/agents/lmstudio_base_chat.toml | 11 + sources/agents/lmstudio_chat.toml | 8 +- sources/agents/lmstudio_responses.toml | 8 +- sources/agents/lmstudio_responses_base.toml | 11 + sources/agents/mistral_base_chat.toml | 11 + sources/agents/mistral_chat.toml | 8 +- sources/agents/mistral_medium_chat.toml | 6 +- sources/agents/mistral_reasoning_chat.toml | 6 +- sources/agents/ollama_openai_base_chat.toml | 11 + sources/agents/ollama_openai_chat.toml | 8 +- sources/agents/ollama_qwen25_coder_fim.toml | 15 + .../agents/openai_compatible_base_chat.toml | 10 + sources/agents/openai_compatible_chat.toml | 7 +- sources/agents/openrouter_base_chat.toml | 11 + sources/agents/openrouter_chat.toml | 8 +- sources/settings/AgentSelectionDialog.cpp | 5 +- sources/settings/AgentSlotWidget.cpp | 4 +- sources/settings/PipelinesConfig.cpp | 8 +- test/AgentLoaderTest.cpp | 184 ++++++++++ test/BundledAgentsTest.cpp | 4 + test/CMakeLists.txt | 1 + 37 files changed, 822 insertions(+), 133 deletions(-) create mode 100644 docs/creating-agents.md delete mode 100644 sources/agents/claude_sonnet_chat.toml create mode 100644 sources/agents/codestral_base_chat.toml create mode 100644 sources/agents/codestral_fim_base.toml create mode 100644 sources/agents/llamacpp_base_chat.toml create mode 100644 sources/agents/lmstudio_base_chat.toml create mode 100644 sources/agents/lmstudio_responses_base.toml create mode 100644 sources/agents/mistral_base_chat.toml create mode 100644 sources/agents/ollama_openai_base_chat.toml create mode 100644 sources/agents/ollama_qwen25_coder_fim.toml create mode 100644 sources/agents/openai_compatible_base_chat.toml create mode 100644 sources/agents/openrouter_base_chat.toml create mode 100644 test/AgentLoaderTest.cpp diff --git a/README.md b/README.md index 40fb928..70ac923 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ For optimal coding assistance, we recommend using these top-tier models: ### Additional Configuration +- **[Creating and Extending Agents](docs/creating-agents.md)** - Add custom agents or override bundled ones with TOML profiles - **[Agent Roles](docs/agent-roles.md)** - Create AI personas with specialized system prompts - **[Chat Summarization](docs/chat-summarization.md)** - Compress conversations to save context tokens - **[Ignoring Files](docs/ignoring-files.md)** - Exclude files from context using `.qodeassistignore` @@ -531,7 +532,7 @@ If you find QodeAssist helpful, there are several ways you can support the proje 1. **Report Issues**: If you encounter any bugs or have suggestions for improvements, please [open an issue](https://github.com/Palm1r/qodeassist/issues) on our GitHub repository. -2. **Contribute**: Feel free to submit pull requests with bug fixes or new features. +2. **Contribute**: Feel free to submit pull requests with bug fixes or new features. The easiest contribution is an agent preset for a provider or model you use — it's a single TOML file, no C++ required; see [Contributing your agent](docs/creating-agents.md#contributing-your-agent-to-qodeassist). 3. **Spread the Word**: Star our GitHub repository and share QodeAssist with your fellow developers. @@ -579,6 +580,10 @@ cmake --build . ## For Contributors +### Adding an agent preset + +New provider/model presets are plain TOML — extend a provider base, register the file in `agents.qrc`, and the test suite validates it automatically. Step-by-step guide: [docs/creating-agents.md](docs/creating-agents.md#contributing-your-agent-to-qodeassist). + ### Code Style - **QML**: Follow [QML Coding Guide](https://github.com/Furkanzmc/QML-Coding-Guide) by @Furkanzmc diff --git a/docs/agent-templates-design.md b/docs/agent-templates-design.md index 7f9be5c..87e902e 100644 --- a/docs/agent-templates-design.md +++ b/docs/agent-templates-design.md @@ -79,22 +79,31 @@ A missing/typo'd partial is a **load-time** error. ### 3. `extends` shares config down a hierarchy `extends` already exists (`resolveExtends` + `deepMerge` + `abstract`/`hidden`); it -keeps doing what it does, now over the structured `[body]` too. A flat 2 levels — each -provider base sets `system_prompt = """{{ agent_role("developer") }}"""` (the role text -comes from the role JSON via the `agent_role` callback; see below). No shared root base: +keeps doing what it does, now over the structured `[body]` too. Each API-shape base +sets `system_prompt = """{{ agent_role() }}"""` (the role text comes from the role +JSON via the `agent_role` callback; see below). No shared root base. Between the +API-shape base and the concrete agents sits one thin abstract base **per provider** +(provider_instance + endpoint only) — the designated extension point for user +agents, so a custom agent is `extends` + `name` + `model`: ``` -openai_base (abstract) → system_prompt + provider/endpoint/enable_tools + [body] - ├─ openai_chat → name - ├─ mistral_chat → name, provider, endpoint - └─ mistral_reasoning → + enable_thinking -anthropic_base (abstract) → system_prompt + provider/endpoint + [body] - ├─ claude_chat → name - └─ claude_sonnet → + [body] thinking / output_config -google_base (abstract) → system_prompt + provider + [body] - └─ gemini_chat → endpoint (${MODEL}) + [body.generationConfig] thinkingConfig +openai_base (abstract) → system_prompt + [body] (API shape) + ├─ mistral_base (abstract) → provider, endpoint (per-provider) + │ ├─ mistral_chat → name, model + │ └─ mistral_reasoning → name, model + enable_thinking + ├─ openrouter_base (abstract) ... + └─ openai_chat → name, model (own provider = no mid layer) +anthropic_base (abstract) → system_prompt + provider/endpoint + [body] + └─ claude_sonnet46 → name, model + [body] thinking / output_config +google_base (abstract) → system_prompt + provider + [body] + └─ gemini_chat → endpoint (${MODEL}) + [body.generationConfig] thinkingConfig ``` +Bundled agents are read-only: the loader rejects a user file that reuses a bundled +`name`. Customisation = a user agent under a new name extending a bundled base (or a +concrete bundled agent); the per-agent model override in settings covers the +model-only case without any file. + Notes: - `[body]` is shared whole when identical (the 8 OpenAI-compatible providers); a variant overrides only the differing field — no duplicated body. diff --git a/docs/architecture.md b/docs/architecture.md index b5c4367..fca139d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,8 +161,13 @@ string values render and splice as raw JSON, literals pass through, empty render drop the key). `include` resolves only sandboxed partial roots. Profiles validate at load: a referenced partial must resolve and the assembled body must parse as JSON against a synthetic context — config errors surface in the agents settings -page, never as a silent runtime drop. Full spec: -[`agent-templates-design.md`](agent-templates-design.md). +page, never as a silent runtime drop. The loader also lints: unknown top-level / +`[match]` keys and same-layer duplicate names are warnings; a user file that +reuses a bundled agent's name is rejected (bundled agents cannot be replaced — +users extend them, or the per-provider abstract bases, under a new name); +`abstract` and `hidden` are never inherited through `extends`. Full spec: +[`agent-templates-design.md`](agent-templates-design.md); user-facing guide: +[`creating-agents.md`](creating-agents.md). --- diff --git a/docs/creating-agents.md b/docs/creating-agents.md new file mode 100644 index 0000000..c67dac6 --- /dev/null +++ b/docs/creating-agents.md @@ -0,0 +1,339 @@ +# Creating and Extending Agents + +An *agent* is a TOML profile that tells QodeAssist which provider to call, +which model to use, and exactly what request body to send. All bundled agents +(Settings → QodeAssist → Agents) are built from the same files described here — +anything a bundled agent does, a user agent can do too. + +## Where user agents live + +Drop `*.toml` files into the user agents directory: + +| OS | Path | +|---|---| +| Linux / macOS | `~/.config/QtProject/qtcreator/qodeassist/config/agents/` | +| Windows | `%APPDATA%\QtProject\qtcreator\qodeassist\config\agents\` | + +QodeAssist creates the directory on startup. Files are loaded at plugin +startup; after adding or editing a file, restart Qt Creator. + +Two layers are loaded: + +1. **Bundled** agents shipped inside the plugin — read-only. +2. **User** agents from the directory above (marked with a `user` pill). + +Agent `name`s are global across both layers. A user file that reuses a +bundled agent's `name` is rejected with an error — bundled agents cannot be +replaced; create your own agent under a new name and `extends` what you want +to build on. Two *user* files with the same `name` produce a warning, and +the alphabetically later file wins. + +Load errors and warnings (TOML syntax, unknown keys, missing `extends` +parents, bodies that don't render to valid JSON) are reported in Qt Creator's +**General Messages** pane, prefixed with `[Agents]`. + +## Minimal example + +```toml +schema_version = 1 + +name = "My DeepSeek Chat" +description = "DeepSeek V3 via an OpenAI-compatible endpoint." + +provider_instance = "OpenAI Compatible" +model = "deepseek-chat" +endpoint = "/chat/completions" + +system_prompt = """{{ agent_role() }}""" + +[body] +max_tokens = 8192 +temperature = 0.7 +stream = true +messages = """ +[ {% include "partials/openai_messages.jinja" %} ] +""" +``` + +Shorter still — extend a bundled provider base and state only the delta: + +```toml +schema_version = 1 + +extends = "OpenAI Compatible Base Chat" +name = "My DeepSeek Chat" +model = "deepseek-chat" +``` + +Bundled agents themselves are read-only. To get a variant of a preset, create +your own agent under a new name and put it where you want it in the roster: + +```toml +schema_version = 1 + +extends = "Mistral Base Chat" +name = "My Mistral (low temp)" +model = "mistral-small-latest" + +[body] +temperature = 0.3 +``` + +If all you want is a different model for a preset, you don't need a file at +all — set the per-agent model override in the settings UI. + +## Key reference + +| Key | Required | Meaning | +|---|---|---| +| `schema_version` | no (default 1) | Format version; the plugin refuses files newer than it supports. | +| `name` | yes | Unique identifier; shown in the UI, referenced by rosters and `extends`. | +| `description` | no | Tooltip text in the Agents list. | +| `provider_instance` | yes* | Name of a provider instance (see below). | +| `model` | yes* | Default model; can be overridden per agent in settings. | +| `endpoint` | yes* | Path appended to the provider instance URL. May contain `${MODEL}` (e.g. Google: `/models/${MODEL}:streamGenerateContent?alt=sse`). | +| `system_prompt` | no | Jinja template for the system prompt (see building blocks below). FIM agents usually omit it. | +| `tags` | no | Free-form strings shown as pills in the UI for discoverability. | +| `enable_thinking` | no | Capability hint (UI badge). The `[body]` is the source of truth for what is sent. | +| `enable_tools` | no | Lets the provider inject tool definitions into the request. | +| `cache_prompt` / `cache_ttl` | no | Prompt caching (Anthropic); `cache_ttl = "1h"` selects the long TTL. | +| `extends` | no | Name of a parent agent to inherit from. | +| `abstract` | no | Mark as template-only: it can be extended but is never loaded as a usable agent. Not inherited. | +| `hidden` | no | Loaded and usable, but not listed in selection UIs. Not inherited. | +| `[match]` | no | Routing constraints (see Routing). | +| `[body]` | yes* | The literal request body (see below). | + +\* required after `extends` resolution — a child inherits these from its +parent, so it only states what differs. + +### Required keys checked at load + +A concrete (non-abstract) agent must end up with `name`, +`provider_instance`, `model`, `endpoint`, and a non-empty `[body]`. Unknown +keys anywhere at the top level or in `[match]` produce a warning — this +catches typos like `enable_thinkin`. + +## Provider instances + +`provider_instance` refers to a provider configuration (URL + API key +reference + client API). Bundled instances: + +`Claude`, `Codestral`, `Google AI`, `llama.cpp`, +`LM Studio (Chat Completions)`, `LM Studio (Responses API)`, `Mistral AI`, +`Ollama (Native)`, `Ollama (OpenAI-compatible)`, `OpenAI (Chat Completions)`, +`OpenAI (Responses API)`, `OpenAI Compatible`, `OpenRouter`. + +User-defined instances live next to agents in +`…/qodeassist/config/providers/*.toml` and follow the same +override-by-name layering. + +## `extends` — inheriting from another agent + +A child deep-merges over its parent: scalar keys are replaced, tables (such +as `[body]` and `[body.options]`) merge key-by-key, and **arrays are replaced +whole** (a child that wants the parent's `tags` plus one more must restate +the full list). Chains can be deeper than one level; cycles and missing +parents are load errors. + +`abstract` and `hidden` are never inherited — extending a hidden agent +yields a visible child unless the child says otherwise. + +Every provider ships an abstract base that already carries the provider +instance, endpoint, and request body — extending one and setting `model` is +usually all a custom agent needs: + +| Base | Provider / API | +|---|---| +| `Anthropic Base Chat` | Claude, Anthropic Messages (`/v1/messages`) | +| `OpenAI Base Chat` | OpenAI, Chat Completions (`/chat/completions`) | +| `OpenAI Responses Base` | OpenAI, Responses API (`/responses`) | +| `OpenAI Compatible Base Chat` | Any OpenAI-compatible server | +| `Google Base Chat` | Google AI, Gemini `generateContent` | +| `Mistral Base Chat` | Mistral AI, Chat Completions | +| `Codestral Base Chat` | Codestral, Chat Completions | +| `Codestral FIM Base` | Mistral AI, `/v1/fim/completions` code completion | +| `OpenRouter Base Chat` | OpenRouter, Chat Completions | +| `LM Studio Base Chat` | LM Studio, Chat Completions | +| `LM Studio Responses Base` | LM Studio, Responses API | +| `llama.cpp Base Chat` | llama.cpp server, Chat Completions | +| `Ollama Base Chat` | Ollama, native `/api/chat` | +| `Ollama (OpenAI-compatible) Base Chat` | Ollama, OpenAI-compatible endpoint | +| `Ollama FIM Base` | Ollama, native `/api/generate` fill-in-the-middle | + +Concrete agents work as parents too — `extends = "Claude Sonnet 4.6 Chat"` +inherits everything including the model. + +## `[body]` — the request, literally + +`[body]` is the request body, written exactly like the provider's curl +example. Per key, recursively: + +- **string containing jinja** (`{{` or `{%`) — rendered, and the output is + spliced in as raw JSON. A render that produces nothing drops the key. +- **plain string / number / bool / table** — passed through unchanged. + +```toml +[body] +max_tokens = 16000 +stream = true +thinking = { type = "adaptive", display = "summarized" } +messages = """ +[ {% include "partials/anthropic_messages.jinja" %} ] +""" +``` + +There are no runtime toggles: a thinking variant is a separate agent file +that carries the thinking fields in its body. + +Every agent body is dry-run rendered at load against a synthetic +conversation (text, thinking, tool calls, tool results, images), so jinja +syntax errors, unknown callbacks, missing partials, and invalid JSON are +reported at startup — not mid-conversation. Trailing commas emitted by loops +are stripped automatically; don't bother with `loop.is_last` bookkeeping. + +### Template data (`ctx`) + +| Field | Content | +|---|---| +| `ctx.system_prompt` | Rendered system prompt (present only if the agent has one). | +| `ctx.prefix` / `ctx.suffix` | Code around the cursor (FIM/completion sessions). | +| `ctx.files_metadata` | Array of `{ file_path, content }` for attached files. | +| `ctx.history` | Array of messages: `{ role, content, content_blocks, images? }`. | + +`content` is the message's flattened text; `content_blocks` is the +structured form: + +| `type` | Fields | +|---|---| +| `text` | `text` | +| `thinking` | `thinking`, `signature` | +| `redacted_thinking` | `data` | +| `tool_use` | `id`, `name`, `input` (JSON object) | +| `tool_result` | `tool_use_id`, `content`, `name` | +| `image` | `data`, `media_type`, `is_url` | + +### Callbacks available in `[body]` + +| Callback | Purpose | +|---|---| +| `tojson(x)` | Serialize any value as JSON (correct quoting/escaping). Use it for every interpolated value. | +| `filter_by_type(blocks, "tool_use")` | Subset of `content_blocks` with the given type. | +| `filter_skip_role(history, "system")` | History without messages of a role. | +| `strip_signature_suffix(s)` | Remove a trailing `[Signature: …]` marker. | + +### Partials and `{% include %}` + +The repetitive message-array rendering lives in shared partials. Includes +resolve against the bundled set first, then the user agent's own directory — +so a user agent can both reuse bundled partials and ship its own next to its +TOML (e.g. `partials/my_messages.jinja`). Paths with `..` or a leading `/` +are rejected. + +Bundled partials: `partials/openai_messages.jinja`, +`partials/openai_responses_input.jinja`, `partials/anthropic_messages.jinja`, +`partials/google_contents.jinja`, `partials/ollama_messages.jinja` (plus the +per-message helpers they include). + +## `system_prompt` — composable building blocks + +`system_prompt` is itself a jinja template, rendered with: + +| Helper | Purpose | +|---|---| +| `{{ agent_role() }}` | Text of the role currently selected in the chat (falls back to `developer`). | +| `{{ agent_role("reviewer") }}` | A specific role by id (Settings → QodeAssist → Roles). | +| `{{ read_file("${PROJECT_DIR}/STYLE.md") }}` | Inline a file. Reads are restricted to the project directory and `~/qodeassist`. | +| `{{ file_exists(p) }}` / `{{ read_dir(p) }}` | Existence check / directory listing (same root restrictions). | +| `{{ head_lines(s, n) }}` | First `n` lines of a string. | +| `basename`, `dirname`, `ext`, `lower`, `upper` | Path/string helpers. | +| `${PROJECT_DIR}`, `${HOME}` | Substituted before rendering. | + +Example: + +```toml +system_prompt = """ +{{ agent_role() }} + +{% if file_exists("${PROJECT_DIR}/.qodeassist-style.md") %} +Project conventions: +{{ read_file("${PROJECT_DIR}/.qodeassist-style.md") }} +{% endif %} +""" +``` + +## Routing — `[match]` and rosters + +Each pipeline (code completion, chat, compression, quick refactor) has an +ordered roster of agents. For the current file, the **first roster entry +whose `[match]` accepts** wins. + +```toml +[match] +file_patterns = ["*.qml", "*.js"] +path_patterns = ["*/tests/*"] +project_names = ["MyProject"] +``` + +- Dimensions are ANDed; an empty dimension is unconstrained; an entirely + empty/absent `[match]` is a catch-all. +- `file_patterns` are case-insensitive globs tested against the file name + and the full path; `path_patterns` against the full path only. +- `project_names` are exact, case-sensitive project names. + +Typical setup: a specialized agent (e.g. `Qt CodeLlama 13B QML FIM` with +`*.qml`) first, a catch-all agent last. + +## Models + +The TOML `model` is only the default. The settings UI can set a per-agent +override (stored in `agent_models.json`); the resolved model is also +substituted into `${MODEL}` in `endpoint` before sending. + +## Contributing your agent to QodeAssist + +The bundled agent set grows through contributions — if you've made an agent +for a provider or model that others could use, please send it upstream +instead of keeping it local. No C++ is needed: + +1. Develop and verify the agent locally in the user agents directory. +2. In a fork, copy the TOML to `sources/agents/` and register the file in + `sources/agents/agents.qrc`. +3. Keep it a thin delta: extend the matching provider base and set only + `name`, `description`, `model`, `tags` (and `[body]` keys that genuinely + differ). Look at `mistral_chat.toml` or `ollama_qwen25_coder_fim.toml` + for the expected shape. +4. Run the tests (`QodeAssistTest`): `BundledAgentsTest` automatically + loads every bundled agent, resolves its `extends` chain, and dry-renders + its `[body]` — if your TOML passes, it works. +5. Open a pull request. + +Conventions: + +- File name: `__.toml` + (e.g. `ollama_qwen25_coder_fim.toml`). +- `name` is user-visible and must be unique; include the provider and model + (e.g. `Ollama Qwen2.5-Coder FIM`). +- Specialized completion agents should carry a `[match]` block so routing + can pick them automatically (e.g. `file_patterns = ["*.qml"]`). +- A whole new provider with an OpenAI-compatible API is also TOML-only: a + provider instance file in `sources/providersConfig/`, one abstract + ` Base Chat`, and concrete agents on top. New request/response + *formats* are the only thing that needs C++. + +## Troubleshooting + +- **Agent missing from the list** — check General Messages for `[Agents] + error:` lines; the file failed to parse, resolve, or validate. +- **`… has the same name as a bundled agent — bundled agents cannot be + replaced`** — pick a different `name`; use `extends` to inherit from the + bundled agent instead. +- **`Unknown key 'x' … ignored (typo?)`** — the key isn't part of the + schema; compare with the table above. +- **`Agent 'X' extends unknown agent 'Y'`** — the parent's `name` (not file + name) must match exactly; the parent must be bundled or in the same + directory. +- **`[body] failed to render to valid JSON`** — the dry run failed; the log + contains the rendered snippet. Usually a missing `tojson(...)` around an + interpolated string. +- **Edits not picked up** — agents are loaded at startup; restart + Qt Creator. diff --git a/sources/agents/AgentConfig.hpp b/sources/agents/AgentConfig.hpp index c986475..7de5a9b 100644 --- a/sources/agents/AgentConfig.hpp +++ b/sources/agents/AgentConfig.hpp @@ -48,7 +48,6 @@ struct AgentConfig bool hidden = false; QString sourcePath; - bool overridesBundled = false; bool isUserSource() const { return !sourcePath.startsWith(QLatin1StringView{":/"}); } static QString validate(const AgentConfig &config); diff --git a/sources/agents/AgentFactory.cpp b/sources/agents/AgentFactory.cpp index 1641270..8ecaac9 100644 --- a/sources/agents/AgentFactory.cpp +++ b/sources/agents/AgentFactory.cpp @@ -59,6 +59,7 @@ void AgentFactory::reload() Q_ASSERT(thread() == QThread::currentThread()); clear(); + QDir().mkpath(userAgentsDir()); auto result = Agents::AgentLoader::load(agentQrcPrefix(), userAgentsDir()); for (const QString &err : result.errors) LOG_MESSAGE(QString("[Agents] error: %1").arg(err)); diff --git a/sources/agents/AgentLoader.cpp b/sources/agents/AgentLoader.cpp index f7066be..eb7e051 100644 --- a/sources/agents/AgentLoader.cpp +++ b/sources/agents/AgentLoader.cpp @@ -145,16 +145,49 @@ struct RawEntry { QJsonObject obj; QString filePath; - bool overridesBundled = false; + bool isUserLayer = false; }; constexpr int kMaxExtendsDepth = 32; +void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringList &warnings) +{ + static const QSet kTopLevelKeys = { + QStringLiteral("schema_version"), QStringLiteral("name"), + QStringLiteral("description"), QStringLiteral("provider_instance"), + QStringLiteral("model"), QStringLiteral("endpoint"), + QStringLiteral("system_prompt"), QStringLiteral("tags"), + QStringLiteral("match"), QStringLiteral("enable_thinking"), + QStringLiteral("enable_tools"), QStringLiteral("cache_prompt"), + QStringLiteral("cache_ttl"), QStringLiteral("body"), + QStringLiteral("extends"), QStringLiteral("abstract"), + QStringLiteral("hidden")}; + static const QSet kMatchKeys = { + QStringLiteral("file_patterns"), + QStringLiteral("path_patterns"), + QStringLiteral("project_names")}; + + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) { + if (!kTopLevelKeys.contains(it.key())) { + warnings.append(QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)") + .arg(it.key(), filePath)); + } + } + const QJsonObject matchObj = obj.value("match").toObject(); + for (auto it = matchObj.constBegin(); it != matchObj.constEnd(); ++it) { + if (!kMatchKeys.contains(it.key())) { + warnings.append(QStringLiteral("Unknown key 'match.%1' in %2 — ignored (typo?)") + .arg(it.key(), filePath)); + } + } +} + void scanDir( const QString &dir, bool isUserLayer, QHash &raw, - QStringList &errors) + QStringList &errors, + QStringList *warnings) { if (dir.isEmpty()) return; QDir d(dir); @@ -173,11 +206,39 @@ void scanDir( errors.append(QStringLiteral("Agent at %1 has no 'name'").arg(fullPath)); continue; } - const bool overrides = isUserLayer && raw.contains(name); - raw.insert(name, {*objOpt, fullPath, overrides}); + if (warnings) + lintUnknownKeys(*objOpt, fullPath, *warnings); + const auto existing = raw.constFind(name); + if (existing != raw.constEnd() && existing->isUserLayer != isUserLayer) { + errors.append( + QStringLiteral("Agent '%1' at %2 has the same name as a bundled agent — " + "bundled agents cannot be replaced; rename it and use " + "'extends' to build on the bundled one") + .arg(name, fullPath)); + continue; + } + if (warnings && existing != raw.constEnd()) { + warnings->append( + QStringLiteral("Agent '%1' is defined in both %2 and %3 — %3 wins") + .arg(name, existing->filePath, fullPath)); + } + raw.insert(name, {*objOpt, fullPath, isUserLayer}); } } +QJsonObject mergeChild(const QJsonObject &parentMerged, const QJsonObject &self, const QString &name) +{ + QJsonObject merged = deepMerge(parentMerged, self); + merged["name"] = name; + for (const QString &key : {QStringLiteral("abstract"), QStringLiteral("hidden")}) { + if (self.contains(key)) + merged[key] = self.value(key); + else + merged.remove(key); + } + return merged; +} + QJsonObject resolveExtends( const QString &name, const QHash &raw, @@ -196,7 +257,7 @@ QJsonObject resolveExtends( return {}; } if (!raw.contains(name)) { - errors.append(QStringLiteral("Unknown parent agent '%1'").arg(name)); + errors.append(QStringLiteral("Unknown agent '%1'").arg(name)); return {}; } visiting.insert(name); @@ -204,15 +265,15 @@ QJsonObject resolveExtends( QJsonObject self = raw.value(name).obj; const QString parent = self.value("extends").toString(); if (!parent.isEmpty()) { + if (!raw.contains(parent)) { + errors.append(QStringLiteral("Agent '%1' extends unknown agent '%2' (%3)") + .arg(name, parent, raw.value(name).filePath)); + visiting.remove(name); + return {}; + } const QJsonObject parentMerged = resolveExtends(parent, raw, visiting, errors, depth + 1); - QJsonObject merged = deepMerge(parentMerged, self); - merged["name"] = name; - if (self.contains("abstract")) - merged["abstract"] = self.value("abstract"); - else - merged.remove("abstract"); - self = merged; + self = mergeChild(parentMerged, self, name); } visiting.remove(name); return self; @@ -224,7 +285,7 @@ std::optional AgentLoader::parseFile( const QString &path, const QString &qrcPrefix, QString *error, - QStringList * /*warnings*/) + QStringList *warnings) { auto objOpt = parseTomlFile(path, error); if (!objOpt) return std::nullopt; @@ -234,12 +295,14 @@ std::optional AgentLoader::parseFile( if (error) *error = QStringLiteral("Agent at %1 has no 'name'").arg(path); return std::nullopt; } + if (warnings) + lintUnknownKeys(*objOpt, path, *warnings); QHash raw; QStringList scanErrors; - scanDir(qrcPrefix, /*isUserLayer=*/false, raw, scanErrors); - scanDir(QFileInfo(path).absolutePath(), /*isUserLayer=*/true, raw, scanErrors); - raw.insert(name, {*objOpt, path, raw.contains(name)}); + scanDir(qrcPrefix, /*isUserLayer=*/false, raw, scanErrors, nullptr); + scanDir(QFileInfo(path).absolutePath(), /*isUserLayer=*/true, raw, scanErrors, nullptr); + raw.insert(name, {*objOpt, path, true}); QSet visiting; QStringList resolveErrors; @@ -270,8 +333,8 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin LoadResult result; QHash raw; - scanDir(qrcPrefix, /*isUserLayer=*/false, raw, result.errors); - scanDir(userDir, /*isUserLayer=*/true, raw, result.errors); + scanDir(qrcPrefix, /*isUserLayer=*/false, raw, result.errors, &result.warnings); + scanDir(userDir, /*isUserLayer=*/true, raw, result.errors, &result.warnings); for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) { const QString &name = it.key(); @@ -282,7 +345,6 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin AgentConfig cfg = configFromMerged(merged); cfg.sourcePath = it.value().filePath; - cfg.overridesBundled = it.value().overridesBundled; if (cfg.abstract) continue; diff --git a/sources/agents/AgentRouter.cpp b/sources/agents/AgentRouter.cpp index 7135eb4..58607c6 100644 --- a/sources/agents/AgentRouter.cpp +++ b/sources/agents/AgentRouter.cpp @@ -5,6 +5,9 @@ #include "AgentRouter.hpp" #include +#include +#include +#include #include #include "AgentFactory.hpp" @@ -13,16 +16,29 @@ namespace QodeAssist::AgentRouter { namespace { +QRegularExpression compiledGlob(const QString &pattern) +{ + static QHash cache; + static QMutex mutex; + QMutexLocker lock(&mutex); + const auto it = cache.constFind(pattern); + if (it != cache.constEnd()) + return *it; + const QRegularExpression re( + QRegularExpression::anchoredPattern( + QRegularExpression::wildcardToRegularExpression( + pattern, QRegularExpression::NonPathWildcardConversion)), + QRegularExpression::CaseInsensitiveOption); + cache.insert(pattern, re); + return re; +} + bool matchesAnyGlob(const QStringList &patterns, const QString &subject) { if (subject.isEmpty()) return false; for (const QString &pat : patterns) { - const QRegularExpression re( - QRegularExpression::anchoredPattern( - QRegularExpression::wildcardToRegularExpression( - pat, QRegularExpression::NonPathWildcardConversion)), - QRegularExpression::CaseInsensitiveOption); + const QRegularExpression re = compiledGlob(pat); if (re.isValid() && re.match(subject).hasMatch()) return true; } diff --git a/sources/agents/agents.qrc b/sources/agents/agents.qrc index b31913e..234e3f6 100644 --- a/sources/agents/agents.qrc +++ b/sources/agents/agents.qrc @@ -18,6 +18,15 @@ google_base_chat.toml ollama_base_chat.toml ollama_base_fim.toml + mistral_base_chat.toml + codestral_base_chat.toml + codestral_fim_base.toml + openrouter_base_chat.toml + lmstudio_base_chat.toml + lmstudio_responses_base.toml + llamacpp_base_chat.toml + ollama_openai_base_chat.toml + openai_compatible_base_chat.toml openai_chat.toml openai_compatible_chat.toml @@ -35,8 +44,8 @@ ollama_gemma4_e4b_chat.toml ollama_codellama_7b_code_fim.toml ollama_codellama_13b_qml_fim.toml + ollama_qwen25_coder_fim.toml - claude_sonnet_chat.toml claude_sonnet46_chat.toml claude_haiku45_chat.toml claude_opus_max.toml diff --git a/sources/agents/claude_sonnet_chat.toml b/sources/agents/claude_sonnet_chat.toml deleted file mode 100644 index b781fec..0000000 --- a/sources/agents/claude_sonnet_chat.toml +++ /dev/null @@ -1,14 +0,0 @@ -schema_version = 1 - -extends = "Anthropic Base Chat" -name = "Claude Sonnet Chat" -description = "Anthropic Claude Sonnet 4.6 (Messages API) — coding chat assistant with thinking." - -model = "claude-sonnet-4-6" - -enable_thinking = true -tags = ["chat", "claude", "anthropic", "cloud"] - -[body] -max_tokens = 8192 -thinking = { type = "enabled", budget_tokens = 4096 } diff --git a/sources/agents/codestral_base_chat.toml b/sources/agents/codestral_base_chat.toml new file mode 100644 index 0000000..c87c731 --- /dev/null +++ b/sources/agents/codestral_base_chat.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "Codestral Base Chat" +description = "Mistral Codestral Chat Completions. Abstract — extend it and set model." +abstract = true + +provider_instance = "Codestral" +endpoint = "/v1/chat/completions" + +tags = ["chat", "codestral", "mistral", "cloud"] diff --git a/sources/agents/codestral_chat.toml b/sources/agents/codestral_chat.toml index 914473f..ead4f03 100644 --- a/sources/agents/codestral_chat.toml +++ b/sources/agents/codestral_chat.toml @@ -1,11 +1,7 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "Codestral Base Chat" name = "Codestral Chat" description = "Mistral Codestral (Chat Completions API) — coding chat assistant." -provider_instance = "Codestral" -endpoint = "/v1/chat/completions" -model = "codestral-latest" - -tags = ["chat", "codestral", "mistral", "cloud"] +model = "codestral-latest" diff --git a/sources/agents/codestral_fim.toml b/sources/agents/codestral_fim.toml index 6de5641..76aab37 100644 --- a/sources/agents/codestral_fim.toml +++ b/sources/agents/codestral_fim.toml @@ -1,19 +1,9 @@ schema_version = 1 +extends = "Codestral FIM Base" name = "Codestral FIM" description = "Mistral Codestral fill-in-the-middle code completion (/v1/fim/completions)." -provider_instance = "Mistral AI" -endpoint = "/v1/fim/completions" -model = "codestral-latest" +model = "codestral-latest" -enable_thinking = false -enable_tools = false tags = ["fim", "codestral", "mistral", "cloud", "completion"] - -[body] -max_tokens = 256 -temperature = 0.2 -stream = true -prompt = """{{ tojson(ctx.prefix) }}""" -suffix = """{% if existsIn(ctx, "suffix") %}{{ tojson(ctx.suffix) }}{% endif %}""" diff --git a/sources/agents/codestral_fim_base.toml b/sources/agents/codestral_fim_base.toml new file mode 100644 index 0000000..d9290c4 --- /dev/null +++ b/sources/agents/codestral_fim_base.toml @@ -0,0 +1,17 @@ +schema_version = 1 + +name = "Codestral FIM Base" +description = "Mistral fill-in-the-middle completion (/v1/fim/completions). Abstract — extend it and set model." +abstract = true + +provider_instance = "Mistral AI" +endpoint = "/v1/fim/completions" + +tags = ["fim", "mistral", "cloud", "completion"] + +[body] +max_tokens = 256 +temperature = 0.2 +stream = true +prompt = """{{ tojson(ctx.prefix) }}""" +suffix = """{% if existsIn(ctx, "suffix") %}{{ tojson(ctx.suffix) }}{% endif %}""" diff --git a/sources/agents/llamacpp_base_chat.toml b/sources/agents/llamacpp_base_chat.toml new file mode 100644 index 0000000..c414af0 --- /dev/null +++ b/sources/agents/llamacpp_base_chat.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "llama.cpp Base Chat" +description = "llama.cpp server (OpenAI-compatible Chat Completions). Abstract — extend it and set model." +abstract = true + +provider_instance = "llama.cpp" +endpoint = "/v1/chat/completions" + +tags = ["chat", "llamacpp", "local"] diff --git a/sources/agents/llamacpp_chat.toml b/sources/agents/llamacpp_chat.toml index b51b3f9..5b5d31c 100644 --- a/sources/agents/llamacpp_chat.toml +++ b/sources/agents/llamacpp_chat.toml @@ -1,11 +1,7 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "llama.cpp Base Chat" name = "llama.cpp Chat" description = "llama.cpp server (OpenAI-compatible Chat Completions) — local coding chat assistant." -provider_instance = "llama.cpp" -endpoint = "/v1/chat/completions" -model = "llama" - -tags = ["chat", "llamacpp", "local"] +model = "llama" diff --git a/sources/agents/lmstudio_base_chat.toml b/sources/agents/lmstudio_base_chat.toml new file mode 100644 index 0000000..4dee52a --- /dev/null +++ b/sources/agents/lmstudio_base_chat.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "LM Studio Base Chat" +description = "LM Studio Chat Completions. Abstract — extend it and set model." +abstract = true + +provider_instance = "LM Studio (Chat Completions)" +endpoint = "/v1/chat/completions" + +tags = ["chat", "lmstudio", "local"] diff --git a/sources/agents/lmstudio_chat.toml b/sources/agents/lmstudio_chat.toml index efe1510..afba4dd 100644 --- a/sources/agents/lmstudio_chat.toml +++ b/sources/agents/lmstudio_chat.toml @@ -1,11 +1,7 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "LM Studio Base Chat" name = "LM Studio Chat" description = "LM Studio (Chat Completions API) — local coding chat assistant." -provider_instance = "LM Studio (Chat Completions)" -endpoint = "/v1/chat/completions" -model = "local-model" - -tags = ["chat", "lmstudio", "local"] +model = "local-model" diff --git a/sources/agents/lmstudio_responses.toml b/sources/agents/lmstudio_responses.toml index db4fc51..2df8104 100644 --- a/sources/agents/lmstudio_responses.toml +++ b/sources/agents/lmstudio_responses.toml @@ -1,11 +1,7 @@ schema_version = 1 -extends = "OpenAI Responses Base" +extends = "LM Studio Responses Base" name = "LM Studio Responses" description = "LM Studio (Responses API) — local coding chat assistant." -provider_instance = "LM Studio (Responses API)" -endpoint = "/v1/responses" -model = "local-model" - -tags = ["chat", "lmstudio", "responses", "local"] +model = "local-model" diff --git a/sources/agents/lmstudio_responses_base.toml b/sources/agents/lmstudio_responses_base.toml new file mode 100644 index 0000000..8a87039 --- /dev/null +++ b/sources/agents/lmstudio_responses_base.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +extends = "OpenAI Responses Base" +name = "LM Studio Responses Base" +description = "LM Studio Responses API. Abstract — extend it and set model." +abstract = true + +provider_instance = "LM Studio (Responses API)" +endpoint = "/v1/responses" + +tags = ["chat", "lmstudio", "responses", "local"] diff --git a/sources/agents/mistral_base_chat.toml b/sources/agents/mistral_base_chat.toml new file mode 100644 index 0000000..0ebc6db --- /dev/null +++ b/sources/agents/mistral_base_chat.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "Mistral Base Chat" +description = "Mistral AI Chat Completions. Abstract — extend it and set model." +abstract = true + +provider_instance = "Mistral AI" +endpoint = "/v1/chat/completions" + +tags = ["chat", "mistral", "cloud"] diff --git a/sources/agents/mistral_chat.toml b/sources/agents/mistral_chat.toml index 96b7f89..8f98dcb 100644 --- a/sources/agents/mistral_chat.toml +++ b/sources/agents/mistral_chat.toml @@ -1,11 +1,7 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "Mistral Base Chat" name = "Mistral Chat" description = "Mistral Large (Chat Completions API) — coding chat assistant." -provider_instance = "Mistral AI" -endpoint = "/v1/chat/completions" -model = "mistral-large-latest" - -tags = ["chat", "mistral", "cloud"] +model = "mistral-large-latest" diff --git a/sources/agents/mistral_medium_chat.toml b/sources/agents/mistral_medium_chat.toml index 7daa48f..e83ce53 100644 --- a/sources/agents/mistral_medium_chat.toml +++ b/sources/agents/mistral_medium_chat.toml @@ -1,11 +1,9 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "Mistral Base Chat" name = "Mistral Medium Chat" description = "Mistral Medium 3.5 (Chat Completions API) — frontier coding/agentic chat." -provider_instance = "Mistral AI" -endpoint = "/v1/chat/completions" -model = "mistral-medium-latest" +model = "mistral-medium-latest" tags = ["chat", "mistral", "medium", "cloud"] diff --git a/sources/agents/mistral_reasoning_chat.toml b/sources/agents/mistral_reasoning_chat.toml index a12e7d4..93a1566 100644 --- a/sources/agents/mistral_reasoning_chat.toml +++ b/sources/agents/mistral_reasoning_chat.toml @@ -1,12 +1,10 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "Mistral Base Chat" name = "Mistral Reasoning Chat" description = "Mistral Magistral Medium — native chain-of-thought reasoning model." -provider_instance = "Mistral AI" -endpoint = "/v1/chat/completions" -model = "magistral-medium-latest" +model = "magistral-medium-latest" enable_thinking = true tags = ["chat", "mistral", "reasoning", "cloud"] diff --git a/sources/agents/ollama_openai_base_chat.toml b/sources/agents/ollama_openai_base_chat.toml new file mode 100644 index 0000000..71a6ae0 --- /dev/null +++ b/sources/agents/ollama_openai_base_chat.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "Ollama (OpenAI-compatible) Base Chat" +description = "Ollama via its OpenAI-compatible Chat Completions endpoint. Abstract — extend it and set model." +abstract = true + +provider_instance = "Ollama (OpenAI-compatible)" +endpoint = "/v1/chat/completions" + +tags = ["chat", "ollama", "local"] diff --git a/sources/agents/ollama_openai_chat.toml b/sources/agents/ollama_openai_chat.toml index c3b867e..7a978a7 100644 --- a/sources/agents/ollama_openai_chat.toml +++ b/sources/agents/ollama_openai_chat.toml @@ -1,11 +1,7 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "Ollama (OpenAI-compatible) Base Chat" name = "Ollama (OpenAI-compatible) Chat" description = "Ollama via its OpenAI-compatible Chat Completions endpoint — local coding chat assistant." -provider_instance = "Ollama (OpenAI-compatible)" -endpoint = "/v1/chat/completions" -model = "qwen2.5-coder" - -tags = ["chat", "ollama", "local"] +model = "qwen2.5-coder" diff --git a/sources/agents/ollama_qwen25_coder_fim.toml b/sources/agents/ollama_qwen25_coder_fim.toml new file mode 100644 index 0000000..70a2cdc --- /dev/null +++ b/sources/agents/ollama_qwen25_coder_fim.toml @@ -0,0 +1,15 @@ +schema_version = 1 + +extends = "Ollama FIM Base" +name = "Ollama Qwen2.5-Coder FIM" +description = "Local Qwen2.5-Coder on Ollama, FIM completion via <|fim_*|> markers." + +model = "qwen2.5-coder" + +tags = ["fim", "ollama", "local", "qwen"] + +[body] +prompt = """{% if existsIn(ctx, "suffix") and length(ctx.suffix) > 0 %}{{ tojson("<|fim_prefix|>" + ctx.prefix + "<|fim_suffix|>" + ctx.suffix + "<|fim_middle|>") }}{% else %}{{ tojson("<|fim_prefix|>" + ctx.prefix + "<|fim_middle|>") }}{% endif %}""" + +[body.options] +stop = ["<|endoftext|>", "<|fim_prefix|>", "<|fim_suffix|>", "<|fim_middle|>", "<|fim_pad|>", "<|repo_name|>", "<|file_sep|>"] diff --git a/sources/agents/openai_compatible_base_chat.toml b/sources/agents/openai_compatible_base_chat.toml new file mode 100644 index 0000000..994bfb8 --- /dev/null +++ b/sources/agents/openai_compatible_base_chat.toml @@ -0,0 +1,10 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "OpenAI Compatible Base Chat" +description = "Any OpenAI-compatible Chat Completions endpoint. Abstract — extend it and set model." +abstract = true + +provider_instance = "OpenAI Compatible" + +tags = ["chat", "openai", "compatible"] diff --git a/sources/agents/openai_compatible_chat.toml b/sources/agents/openai_compatible_chat.toml index 8885ca6..9006164 100644 --- a/sources/agents/openai_compatible_chat.toml +++ b/sources/agents/openai_compatible_chat.toml @@ -1,10 +1,7 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "OpenAI Compatible Base Chat" name = "OpenAI Compatible Chat" description = "Any OpenAI-compatible Chat Completions endpoint — set the model to match your server." -provider_instance = "OpenAI Compatible" -model = "default" - -tags = ["chat", "openai", "compatible"] +model = "default" diff --git a/sources/agents/openrouter_base_chat.toml b/sources/agents/openrouter_base_chat.toml new file mode 100644 index 0000000..c52893a --- /dev/null +++ b/sources/agents/openrouter_base_chat.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +extends = "OpenAI Base Chat" +name = "OpenRouter Base Chat" +description = "OpenRouter (OpenAI-compatible Chat Completions). Abstract — extend it and set model." +abstract = true + +provider_instance = "OpenRouter" +endpoint = "/chat/completions" + +tags = ["chat", "openrouter", "cloud"] diff --git a/sources/agents/openrouter_chat.toml b/sources/agents/openrouter_chat.toml index 80750f3..525a7d8 100644 --- a/sources/agents/openrouter_chat.toml +++ b/sources/agents/openrouter_chat.toml @@ -1,11 +1,7 @@ schema_version = 1 -extends = "OpenAI Base Chat" +extends = "OpenRouter Base Chat" name = "OpenRouter Chat" description = "OpenRouter (OpenAI-compatible Chat Completions) — coding chat assistant." -provider_instance = "OpenRouter" -endpoint = "/chat/completions" -model = "openai/gpt-4o" - -tags = ["chat", "openrouter", "cloud"] +model = "openai/gpt-4o" diff --git a/sources/settings/AgentSelectionDialog.cpp b/sources/settings/AgentSelectionDialog.cpp index 26102b8..e9b0673 100644 --- a/sources/settings/AgentSelectionDialog.cpp +++ b/sources/settings/AgentSelectionDialog.cpp @@ -147,10 +147,7 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent) Pill *sourcePill = nullptr; if (cfg.isUserSource()) { - sourcePill = new Pill( - Pill::User, - cfg.overridesBundled ? Tr::tr("Override") : Tr::tr("User"), - this); + sourcePill = new Pill(Pill::User, Tr::tr("User"), this); } auto *description = new QLabel(this); diff --git a/sources/settings/AgentSlotWidget.cpp b/sources/settings/AgentSlotWidget.cpp index 566e65d..f6a0588 100644 --- a/sources/settings/AgentSlotWidget.cpp +++ b/sources/settings/AgentSlotWidget.cpp @@ -296,9 +296,7 @@ void AgentSlotWidget::setAgentConfig(const AgentConfig &cfg) m_name->setText(cfg.name); if (cfg.isUserSource()) { - m_sourcePill->setText(cfg.overridesBundled - ? Tr::tr("User overrides bundled") - : Tr::tr("User")); + m_sourcePill->setText(Tr::tr("User")); m_sourcePill->show(); } else { m_sourcePill->hide(); diff --git a/sources/settings/PipelinesConfig.cpp b/sources/settings/PipelinesConfig.cpp index 72c79c1..f242620 100644 --- a/sources/settings/PipelinesConfig.cpp +++ b/sources/settings/PipelinesConfig.cpp @@ -95,10 +95,10 @@ void fillMissingFromDefaults(PipelineRosters &r, const toml::table §ion) PipelineRosters PipelineRosters::defaults() { PipelineRosters r; - r.codeCompletion = {QStringLiteral("Ollama Qwen2.5-Coder Completion")}; - r.chatAssistant = {QStringLiteral("Ollama Chat")}; - r.chatCompression = {QStringLiteral("Ollama Compression")}; - r.quickRefactor = {QStringLiteral("Ollama Quick Refactor")}; + r.codeCompletion = {QStringLiteral("Ollama Qwen2.5-Coder FIM")}; + r.chatAssistant = {QStringLiteral("Ollama (OpenAI-compatible) Chat")}; + r.chatCompression = {QStringLiteral("Ollama (OpenAI-compatible) Chat")}; + r.quickRefactor = {QStringLiteral("Ollama (OpenAI-compatible) Chat")}; return r; } diff --git a/test/AgentLoaderTest.cpp b/test/AgentLoaderTest.cpp new file mode 100644 index 0000000..59f4bb5 --- /dev/null +++ b/test/AgentLoaderTest.cpp @@ -0,0 +1,184 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include + +#include +#include +#include + +#include +#include + +using QodeAssist::AgentConfig; +using QodeAssist::Agents::AgentLoader; + +namespace { + +void writeFile(const QString &dir, const QString &name, const QByteArray &contents) +{ + QFile f(dir + QLatin1Char('/') + name); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + f.write(contents); +} + +QByteArray minimalAgent(const QByteArray &name, const QByteArray &extra = {}) +{ + return "name = \"" + name + "\"\n" + "provider_instance = \"P\"\n" + "model = \"m\"\n" + "endpoint = \"/e\"\n" + + extra + + "[body]\n" + "stream = true\n"; +} + +const AgentConfig *findConfig(const AgentLoader::LoadResult &result, const QString &name) +{ + for (const auto &cfg : result.configs) { + if (cfg.name == name) + return &cfg; + } + return nullptr; +} + +bool anyContains(const QStringList &list, const QString &needle) +{ + for (const QString &s : list) { + if (s.contains(needle)) + return true; + } + return false; +} + +} // namespace + +TEST(AgentLoaderTest, WarnsOnUnknownTopLevelAndMatchKeys) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile(dir.path(), "a.toml", + minimalAgent("A", + "enable_thinkin = true\n" + "[match]\n" + "file_pattern = [\"*.cpp\"]\n")); + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString(); + EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("enable_thinkin"))); + EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("match.file_pattern"))); +} + +TEST(AgentLoaderTest, WarnsOnDuplicateNameInSameLayer) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile(dir.path(), "first.toml", minimalAgent("Dup")); + writeFile(dir.path(), "second.toml", minimalAgent("Dup")); + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(anyContains(result.warnings, QStringLiteral("defined in both"))); + const AgentConfig *cfg = findConfig(result, QStringLiteral("Dup")); + ASSERT_NE(cfg, nullptr); + EXPECT_TRUE(cfg->sourcePath.endsWith(QStringLiteral("second.toml"))); +} + +TEST(AgentLoaderTest, UserAgentCollidingWithBundledNameIsRejected) +{ + QTemporaryDir bundled; + QTemporaryDir user; + ASSERT_TRUE(bundled.isValid()); + ASSERT_TRUE(user.isValid()); + writeFile(bundled.path(), "a.toml", minimalAgent("A", "description = \"base\"\n")); + writeFile(user.path(), "a.toml", minimalAgent("A", "description = \"mine\"\n")); + + const auto result = AgentLoader::load(bundled.path(), user.path()); + EXPECT_TRUE(anyContains(result.errors, QStringLiteral("cannot be replaced"))); + const AgentConfig *cfg = findConfig(result, QStringLiteral("A")); + ASSERT_NE(cfg, nullptr); + EXPECT_EQ(cfg->description, QStringLiteral("base")); + EXPECT_FALSE(cfg->isUserSource()); +} + +TEST(AgentLoaderTest, HiddenIsNotInherited) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile(dir.path(), "parent.toml", minimalAgent("Parent", "hidden = true\n")); + writeFile(dir.path(), "child.toml", minimalAgent("Child", "extends = \"Parent\"\n")); + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString(); + const AgentConfig *parent = findConfig(result, QStringLiteral("Parent")); + const AgentConfig *child = findConfig(result, QStringLiteral("Child")); + ASSERT_NE(parent, nullptr); + ASSERT_NE(child, nullptr); + EXPECT_TRUE(parent->hidden); + EXPECT_FALSE(child->hidden); +} + +TEST(AgentLoaderTest, UserAgentExtendsBundledProviderBase) +{ + QTemporaryDir bundled; + QTemporaryDir user; + ASSERT_TRUE(bundled.isValid()); + ASSERT_TRUE(user.isValid()); + writeFile(bundled.path(), "base.toml", + "name = \"Provider Base\"\n" + "abstract = true\n" + "provider_instance = \"P\"\n" + "endpoint = \"/e\"\n" + "[body]\n" + "stream = true\n"); + writeFile(bundled.path(), "a.toml", + "name = \"A\"\n" + "extends = \"Provider Base\"\n" + "model = \"stock-model\"\n"); + writeFile(user.path(), "mine.toml", + "name = \"My A\"\n" + "extends = \"Provider Base\"\n" + "model = \"my-model\"\n" + "[body]\n" + "temperature = 0.2\n"); + + const auto result = AgentLoader::load(bundled.path(), user.path()); + EXPECT_TRUE(result.errors.isEmpty()) << result.errors.join("; ").toStdString(); + const AgentConfig *stock = findConfig(result, QStringLiteral("A")); + ASSERT_NE(stock, nullptr); + EXPECT_EQ(stock->model, QStringLiteral("stock-model")); + const AgentConfig *mine = findConfig(result, QStringLiteral("My A")); + ASSERT_NE(mine, nullptr); + EXPECT_EQ(mine->model, QStringLiteral("my-model")); + EXPECT_EQ(mine->providerInstance, QStringLiteral("P")); + EXPECT_TRUE(mine->body.contains(QStringLiteral("stream"))); + EXPECT_TRUE(mine->body.contains(QStringLiteral("temperature"))); + EXPECT_TRUE(mine->isUserSource()); +} + +TEST(AgentLoaderTest, ExtendsUnknownParentErrorNamesChild) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile(dir.path(), "child.toml", minimalAgent("Child", "extends = \"NoSuchBase\"\n")); + + const auto result = AgentLoader::load(QString(), dir.path()); + EXPECT_TRUE(anyContains(result.errors, QStringLiteral("'Child' extends unknown agent 'NoSuchBase'"))); + EXPECT_EQ(findConfig(result, QStringLiteral("Child")), nullptr); +} + +TEST(AgentLoaderTest, ParseFileReportsWarningsForOwnFileOnly) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + writeFile(dir.path(), "other.toml", minimalAgent("Other", "bogus_key = 1\n")); + writeFile(dir.path(), "target.toml", minimalAgent("Target", "another_bogus = 2\n")); + + QString error; + QStringList warnings; + const auto cfg = AgentLoader::parseFile( + dir.path() + QStringLiteral("/target.toml"), QString(), &error, &warnings); + ASSERT_TRUE(cfg.has_value()) << error.toStdString(); + EXPECT_TRUE(anyContains(warnings, QStringLiteral("another_bogus"))); + EXPECT_FALSE(anyContains(warnings, QStringLiteral("bogus_key"))); +} diff --git a/test/BundledAgentsTest.cpp b/test/BundledAgentsTest.cpp index ee5b822..99842f3 100644 --- a/test/BundledAgentsTest.cpp +++ b/test/BundledAgentsTest.cpp @@ -23,6 +23,10 @@ TEST(BundledAgentsTest, AllBundledAgentsLoadResolveAndRender) << "bundled agent load errors: " << result.errors.join(QStringLiteral("; ")).toStdString(); + EXPECT_TRUE(result.warnings.isEmpty()) + << "bundled agent load warnings: " + << result.warnings.join(QStringLiteral("; ")).toStdString(); + ASSERT_FALSE(result.configs.empty()) << "no bundled agents were loaded from :/agents"; for (const auto &cfg : result.configs) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0e6b472..2a75607 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(QodeAssistTest JsonPromptTemplateTest.cpp ResponseRouterTest.cpp BundledAgentsTest.cpp + AgentLoaderTest.cpp # LLMClientInterfaceTests.cpp unittest_main.cpp )