feat: Add agents and agents settings

This commit is contained in:
Petr Mironychev
2026-05-26 12:30:11 +02:00
parent 51ebe3e523
commit 97236c6069
70 changed files with 4308 additions and 296 deletions

94
sources/agents/Agent.cpp Normal file
View File

@@ -0,0 +1,94 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "Agent.hpp"
#include <QThread>
#include "JsonPromptTemplate.hpp"
#include "PromptTemplate.hpp"
#include "Provider.hpp"
namespace QodeAssist {
using Providers::Provider;
using Templates::JsonPromptTemplate;
using Templates::PromptTemplate;
QString AgentConfig::validate(const AgentConfig &config)
{
if (config.name.isEmpty())
return QStringLiteral("Agent config has no name");
if (config.schemaVersion > AgentConfig::kSupportedSchemaVersion) {
return QStringLiteral(
"Agent config '%1' declares schema_version %2 but this plugin "
"supports at most %3 — update QodeAssist to use this profile")
.arg(config.name)
.arg(config.schemaVersion)
.arg(AgentConfig::kSupportedSchemaVersion);
}
if (config.providerInstance.isEmpty())
return QStringLiteral("Agent config '%1' has no provider_instance").arg(config.name);
if (config.model.isEmpty())
return QStringLiteral("Agent config '%1' has no model").arg(config.name);
if (config.endpoint.isEmpty())
return QStringLiteral("Agent config '%1' has no endpoint").arg(config.name);
if (config.messageFormat.isEmpty()) {
return QStringLiteral("Agent config '%1' has no [template].message_format")
.arg(config.name);
}
return {};
}
Agent::Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *parent)
: QObject(parent)
, m_config(std::move(config))
, m_provider(providerOwned)
{
m_invalidReason = AgentConfig::validate(m_config);
if (!m_invalidReason.isEmpty())
return;
if (!m_provider) {
m_invalidReason
= QStringLiteral("Agent '%1' was constructed without a provider").arg(m_config.name);
return;
}
m_provider->setParent(this);
QString tmplErr;
m_promptTemplate = JsonPromptTemplate::fromConfig(m_config, &tmplErr);
if (!m_promptTemplate) {
m_invalidReason = tmplErr.isEmpty()
? QStringLiteral("Failed to build prompt template for agent '%1'")
.arg(m_config.name)
: tmplErr;
}
}
Agent::~Agent() = default;
PromptTemplate *Agent::promptTemplate() noexcept
{
return m_promptTemplate.get();
}
const PromptTemplate *Agent::promptTemplate() const noexcept
{
return m_promptTemplate.get();
}
QFuture<QList<QString>> Agent::installedModels()
{
Q_ASSERT_X(thread() == QThread::currentThread(), Q_FUNC_INFO,
"Agent::installedModels called from non-owning thread; "
"the underlying BaseClient is not thread-safe and must be "
"accessed from the Agent's owner thread");
if (!m_provider) {
return QtFuture::makeReadyValueFuture(QList<QString>{});
}
return m_provider->getInstalledModels(m_provider->url());
}
} // namespace QodeAssist

53
sources/agents/Agent.hpp Normal file
View File

@@ -0,0 +1,53 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <memory>
#include <QFuture>
#include <QList>
#include <QObject>
#include <QString>
#include "AgentConfig.hpp"
namespace QodeAssist {
namespace Providers {
class Provider;
}
namespace Templates {
class JsonPromptTemplate;
class PromptTemplate;
}
class Agent : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(Agent)
public:
Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *parent = nullptr);
~Agent() override;
const AgentConfig &config() const noexcept { return m_config; }
Providers::Provider *provider() noexcept { return m_provider; }
const Providers::Provider *provider() const noexcept { return m_provider; }
Templates::PromptTemplate *promptTemplate() noexcept;
const Templates::PromptTemplate *promptTemplate() const noexcept;
bool isValid() const noexcept { return m_invalidReason.isEmpty(); }
QString invalidReason() const { return m_invalidReason; }
QFuture<QList<QString>> installedModels();
private:
AgentConfig m_config;
std::unique_ptr<Templates::JsonPromptTemplate> m_promptTemplate; // owned
Providers::Provider *m_provider = nullptr; // child of this
QString m_invalidReason;
};
} // namespace QodeAssist

View File

@@ -0,0 +1,57 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QJsonObject>
#include <QString>
#include <QStringList>
namespace QodeAssist {
struct AgentConfig
{
static constexpr int kSupportedSchemaVersion = 1;
int schemaVersion = 1;
QString name;
QString description;
QString providerInstance;
QString model;
QString endpoint;
QString role;
QStringList tags;
struct Match
{
QStringList filePatterns;
QStringList pathPatterns;
QStringList projectNames;
[[nodiscard]] bool isEmpty() const noexcept
{
return filePatterns.isEmpty()
&& pathPatterns.isEmpty()
&& projectNames.isEmpty();
}
};
Match match;
bool enableThinking = false;
bool enableTools = false;
QString messageFormat;
QJsonObject sampling;
QJsonObject thinking;
QString context;
QString extendsName;
bool abstract = false;
bool hidden = false;
QString sourcePath;
bool overridesBundled = false;
bool isUserSource() const { return !sourcePath.startsWith(QLatin1StringView{":/"}); }
static QString validate(const AgentConfig &config);
};
} // namespace QodeAssist

View File

@@ -0,0 +1,223 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentFactory.hpp"
#include <QLoggingCategory>
#include <QThread>
#include <coreplugin/icore.h>
#include "Agent.hpp"
#include "AgentLoader.hpp"
#include "Provider.hpp"
#include "ProviderFactory.hpp"
#include "Logger.hpp"
#include "ProviderSecretsStore.hpp"
#include "ProviderInstance.hpp"
#include "ProviderInstanceFactory.hpp"
static inline void initAgentsResource() { Q_INIT_RESOURCE(agents); }
namespace {
Q_LOGGING_CATEGORY(agentFactoryLog, "qodeassist.agentfactory")
QString agentQrcPrefix() { return QStringLiteral(":/agents"); }
} // namespace
namespace QodeAssist {
AgentFactory::AgentFactory(
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
QObject *parent)
: QObject(parent)
, m_instanceFactory(instanceFactory)
, m_secrets(secrets)
{
::initAgentsResource();
reload();
}
AgentFactory::~AgentFactory() = default;
QString AgentFactory::userAgentsDir()
{
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/agents"))
.toFSPathString();
}
void AgentFactory::reload()
{
Q_ASSERT(thread() == QThread::currentThread());
clear();
auto result = Agents::AgentLoader::load(agentQrcPrefix(), userAgentsDir());
for (const QString &err : result.errors)
LOG_MESSAGE(QString("[Agents] error: %1").arg(err));
for (const QString &warn : result.warnings)
LOG_MESSAGE(QString("[Agents] warning: %1").arg(warn));
LOG_MESSAGE(QString("[Agents] Loaded %1 profiles (qrc=%2, user=%3)")
.arg(result.configs.size())
.arg(agentQrcPrefix(), userAgentsDir()));
for (auto &cfg : result.configs) {
LOG_MESSAGE(QString("[Agents] Loaded: %1").arg(cfg.name));
registerConfig(std::move(cfg));
}
m_errors = std::move(result.errors);
m_warnings = std::move(result.warnings);
}
void AgentFactory::registerConfig(AgentConfig config)
{
Q_ASSERT(thread() == QThread::currentThread());
const QString error = AgentConfig::validate(config);
if (!error.isEmpty()) {
qCWarning(agentFactoryLog).noquote() << "Rejected agent config:" << error;
return;
}
const auto it = m_indexByName.constFind(config.name);
if (it != m_indexByName.constEnd()) {
m_configs[it.value()] = std::move(config);
return;
}
m_indexByName.insert(config.name, static_cast<qsizetype>(m_configs.size()));
m_configs.push_back(std::move(config));
}
const AgentConfig *AgentFactory::configByName(const QString &name) const
{
const auto it = m_indexByName.constFind(name);
if (it == m_indexByName.constEnd())
return nullptr;
return &m_configs[it.value()];
}
QStringList AgentFactory::configNames() const
{
QStringList out;
out.reserve(static_cast<qsizetype>(m_configs.size()));
for (const auto &c : m_configs) {
if (c.hidden) continue;
out.append(c.name);
}
return out;
}
namespace {
Providers::Provider *buildProviderForAgent(
const AgentConfig &cfg,
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
QString *errorOut)
{
if (!instanceFactory) {
if (errorOut) {
*errorOut = QStringLiteral(
"Agent '%1' cannot be built — no ProviderInstanceFactory was wired "
"into AgentFactory")
.arg(cfg.name);
}
return nullptr;
}
const Providers::ProviderInstance *inst
= instanceFactory->instanceByName(cfg.providerInstance);
if (!inst) {
if (errorOut) {
*errorOut = QStringLiteral(
"Agent '%1' references unknown provider instance '%2'")
.arg(cfg.name, cfg.providerInstance);
}
return nullptr;
}
const QString validation = Providers::ProviderInstance::validate(
*inst, Providers::ProviderFactory::knownNames());
if (!validation.isEmpty()) {
if (errorOut)
*errorOut = validation;
return nullptr;
}
Providers::Provider *provider = Providers::ProviderFactory::create(inst->clientApi, nullptr);
if (!provider) {
if (errorOut) {
*errorOut = QStringLiteral("Client API '%1' is not registered (instance '%2')")
.arg(inst->clientApi, inst->name);
}
return nullptr;
}
provider->setUrl(inst->url);
if (secrets && !inst->apiKeyRef.isEmpty())
provider->setApiKey(secrets->readKeySync(inst->apiKeyRef));
return provider;
}
} // namespace
Agent *AgentFactory::create(const QString &name, QObject *parent, QString *errorOut) const
{
const AgentConfig *cfg = configByName(name);
if (!cfg) {
if (errorOut)
*errorOut = QStringLiteral("Agent '%1' is not registered").arg(name);
return nullptr;
}
Providers::Provider *provider = buildProviderForAgent(
*cfg, m_instanceFactory.data(), m_secrets.data(), errorOut);
if (!provider)
return nullptr;
auto agent = std::make_unique<Agent>(*cfg, provider, /*parent=*/nullptr);
if (!agent->isValid()) {
if (errorOut)
*errorOut = agent->invalidReason();
return nullptr;
}
agent->setParent(parent);
return agent.release();
}
Agent *AgentFactory::createFromFile(
const QString &tomlPath, QObject *parent, QString *errorOut) const
{
QString parseErr;
QStringList warnings;
auto cfgOpt = Agents::AgentLoader::parseFile(tomlPath, &parseErr, &warnings);
if (!cfgOpt) {
if (errorOut) *errorOut = parseErr;
return nullptr;
}
Providers::Provider *provider = buildProviderForAgent(
*cfgOpt, m_instanceFactory.data(), m_secrets.data(), errorOut);
if (!provider)
return nullptr;
auto agent = std::make_unique<Agent>(std::move(*cfgOpt), provider, /*parent=*/nullptr);
if (!agent->isValid()) {
if (errorOut) *errorOut = agent->invalidReason();
return nullptr;
}
agent->setParent(parent);
return agent.release();
}
void AgentFactory::clear()
{
Q_ASSERT(thread() == QThread::currentThread());
m_configs.clear();
m_indexByName.clear();
m_errors.clear();
m_warnings.clear();
}
Providers::ProviderInstanceFactory *AgentFactory::instanceFactory() const noexcept
{
return m_instanceFactory.data();
}
Providers::ProviderSecretsStore *AgentFactory::secretsStore() const noexcept
{
return m_secrets.data();
}
} // namespace QodeAssist

View File

@@ -0,0 +1,67 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <vector>
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include "AgentConfig.hpp"
namespace QodeAssist {
class Agent;
namespace Providers {
class ProviderInstanceFactory;
class ProviderSecretsStore;
}
class AgentFactory : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(AgentFactory)
public:
explicit AgentFactory(
Providers::ProviderInstanceFactory *instanceFactory = nullptr,
Providers::ProviderSecretsStore *secrets = nullptr,
QObject *parent = nullptr);
~AgentFactory() override;
void reload();
[[nodiscard]] static QString userAgentsDir();
[[nodiscard]] const AgentConfig *configByName(const QString &name) const;
[[nodiscard]] QStringList configNames() const;
[[nodiscard]] const std::vector<AgentConfig> &configs() const noexcept { return m_configs; }
Agent *create(const QString &name, QObject *parent, QString *errorOut = nullptr) const;
Agent *createFromFile(
const QString &tomlPath, QObject *parent, QString *errorOut = nullptr) const;
[[nodiscard]] QStringList lastLoadErrors() const { return m_errors; }
[[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; }
void registerConfig(AgentConfig config);
void clear();
[[nodiscard]] Providers::ProviderInstanceFactory *instanceFactory() const noexcept;
[[nodiscard]] Providers::ProviderSecretsStore *secretsStore() const noexcept;
private:
std::vector<AgentConfig> m_configs;
QHash<QString, qsizetype> m_indexByName;
QStringList m_errors;
QStringList m_warnings;
QPointer<Providers::ProviderInstanceFactory> m_instanceFactory;
QPointer<Providers::ProviderSecretsStore> m_secrets;
};
} // namespace QodeAssist

View File

@@ -0,0 +1,262 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentLoader.hpp"
#include <QDir>
#include <QFile>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QSet>
#include <toml++/toml.hpp>
#include <algorithm>
#include <sstream>
namespace QodeAssist::Agents {
namespace {
QJsonValue tomlToJson(const toml::node &node)
{
if (auto *table = node.as_table()) {
QJsonObject obj;
for (const auto &[key, value] : *table) {
obj.insert(QString::fromStdString(std::string{key.str()}), tomlToJson(value));
}
return obj;
}
if (auto *array = node.as_array()) {
QJsonArray arr;
for (const auto &item : *array) {
arr.append(tomlToJson(item));
}
return arr;
}
if (auto *str = node.as_string()) {
return QString::fromStdString(str->get());
}
if (auto *integer = node.as_integer()) {
return static_cast<qint64>(integer->get());
}
if (auto *floating = node.as_floating_point()) {
return floating->get();
}
if (auto *boolean = node.as_boolean()) {
return boolean->get();
}
return QJsonValue::Null;
}
QJsonObject deepMerge(const QJsonObject &base, const QJsonObject &overlay)
{
QJsonObject result = base;
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
const QJsonValue baseVal = result.value(it.key());
const QJsonValue overlayVal = it.value();
if (baseVal.isObject() && overlayVal.isObject()) {
result[it.key()] = deepMerge(baseVal.toObject(), overlayVal.toObject());
} else {
result[it.key()] = overlayVal;
}
}
return result;
}
QString readUtf8(const QString &path, QString *error)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
if (error) *error = QStringLiteral("Cannot open: %1").arg(path);
return {};
}
return QString::fromUtf8(f.readAll());
}
std::optional<QJsonObject> parseTomlFile(const QString &path, QString *error)
{
QString readErr;
const QString contents = readUtf8(path, &readErr);
if (!readErr.isEmpty()) {
if (error) *error = readErr;
return std::nullopt;
}
toml::table tbl;
try {
tbl = toml::parse(contents.toStdString(), path.toStdString());
} catch (const toml::parse_error &e) {
std::ostringstream oss;
oss << e;
if (error) {
*error = QStringLiteral("TOML parse error in %1: %2")
.arg(path, QString::fromStdString(oss.str()));
}
return std::nullopt;
}
return tomlToJson(tbl).toObject();
}
QStringList stringArray(const QJsonValue &v)
{
QStringList out;
if (!v.isArray()) return out;
for (const auto &elem : v.toArray()) {
if (elem.isString()) out.append(elem.toString());
}
return out;
}
AgentConfig configFromMerged(const QJsonObject &obj)
{
AgentConfig cfg;
cfg.schemaVersion = obj.value("schema_version").toInt(1);
cfg.name = obj.value("name").toString();
cfg.description = obj.value("description").toString();
cfg.providerInstance = obj.value("provider_instance").toString();
cfg.model = obj.value("model").toString();
cfg.endpoint = obj.value("endpoint").toString();
cfg.role = obj.value("role").toString();
cfg.context = obj.value("context").toString();
cfg.enableThinking = obj.value("enable_thinking").toBool(false);
cfg.enableTools = obj.value("enable_tools").toBool(false);
cfg.tags = stringArray(obj.value("tags"));
const QJsonObject matchObj = obj.value("match").toObject();
cfg.match.filePatterns = stringArray(matchObj.value("file_patterns"));
cfg.match.pathPatterns = stringArray(matchObj.value("path_patterns"));
cfg.match.projectNames = stringArray(matchObj.value("project_names"));
cfg.extendsName = obj.value("extends").toString();
cfg.abstract = obj.value("abstract").toBool(false);
cfg.hidden = obj.value("hidden").toBool(false);
const QJsonObject tpl = obj.value("template").toObject();
cfg.messageFormat = tpl.value("message_format").toString();
cfg.sampling = tpl.value("sampling").toObject();
cfg.thinking = tpl.value("thinking").toObject();
return cfg;
}
struct RawEntry
{
QJsonObject obj;
QString filePath;
bool overridesBundled = false;
};
constexpr int kMaxExtendsDepth = 32;
QJsonObject resolveExtends(
const QString &name,
const QHash<QString, RawEntry> &raw,
QSet<QString> &visiting,
QStringList &errors,
int depth = 0)
{
if (depth > kMaxExtendsDepth) {
errors.append(QStringLiteral("Agent extends chain too deep (>%1) at '%2'")
.arg(kMaxExtendsDepth)
.arg(name));
return {};
}
if (visiting.contains(name)) {
errors.append(QStringLiteral("Cyclic 'extends' involving agent '%1'").arg(name));
return {};
}
if (!raw.contains(name)) {
errors.append(QStringLiteral("Unknown parent agent '%1'").arg(name));
return {};
}
visiting.insert(name);
QJsonObject self = raw.value(name).obj;
const QString parent = self.value("extends").toString();
if (!parent.isEmpty()) {
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;
}
visiting.remove(name);
return self;
}
} // namespace
std::optional<AgentConfig> AgentLoader::parseFile(
const QString &path, QString *error, QStringList * /*warnings*/)
{
auto objOpt = parseTomlFile(path, error);
if (!objOpt) return std::nullopt;
AgentConfig cfg = configFromMerged(*objOpt);
cfg.sourcePath = path;
return cfg;
}
AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QString &userDir)
{
LoadResult result;
QHash<QString, RawEntry> raw;
auto scan = [&](const QString &dir, bool isUserLayer) {
if (dir.isEmpty()) return;
QDir d(dir);
if (!d.exists()) return;
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
for (const QString &fname : files) {
const QString fullPath = d.filePath(fname);
QString err;
auto objOpt = parseTomlFile(fullPath, &err);
if (!objOpt) {
result.errors.append(err);
continue;
}
const QString name = objOpt->value("name").toString();
if (name.isEmpty()) {
result.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});
}
};
scan(qrcPrefix, /*isUserLayer=*/false);
scan(userDir, /*isUserLayer=*/true);
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) {
const QString &name = it.key();
QSet<QString> visiting;
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
if (merged.isEmpty()) continue;
AgentConfig cfg = configFromMerged(merged);
cfg.sourcePath = it.value().filePath;
cfg.overridesBundled = it.value().overridesBundled;
if (cfg.abstract) continue;
const QString validation = AgentConfig::validate(cfg);
if (!validation.isEmpty()) {
result.errors.append(validation);
continue;
}
result.configs.push_back(std::move(cfg));
}
std::sort(result.configs.begin(), result.configs.end(),
[](const AgentConfig &a, const AgentConfig &b) { return a.name < b.name; });
return result;
}
} // namespace QodeAssist::Agents

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <QStringList>
#include <vector>
#include "AgentConfig.hpp"
namespace QodeAssist::Agents {
class AgentLoader
{
public:
struct LoadResult
{
std::vector<AgentConfig> configs;
QStringList errors;
QStringList warnings;
};
static LoadResult load(const QString &qrcPrefix, const QString &userDir);
static std::optional<AgentConfig> parseFile(
const QString &path, QString *error, QStringList *warnings = nullptr);
};
} // namespace QodeAssist::Agents

View File

@@ -0,0 +1,85 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "AgentRouter.hpp"
#include <QFileInfo>
#include <QRegularExpression>
#include "AgentFactory.hpp"
namespace QodeAssist::AgentRouter {
namespace {
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);
if (re.isValid() && re.match(subject).hasMatch())
return true;
}
return false;
}
bool matchesFilePatterns(const QStringList &patterns, const QString &filePath)
{
if (patterns.isEmpty())
return true;
if (filePath.isEmpty())
return false;
const QString name = QFileInfo(filePath).fileName();
return matchesAnyGlob(patterns, name) || matchesAnyGlob(patterns, filePath);
}
bool matchesPathPatterns(const QStringList &patterns, const QString &filePath)
{
if (patterns.isEmpty())
return true;
if (filePath.isEmpty())
return false;
return matchesAnyGlob(patterns, filePath);
}
bool matchesProjectNames(const QStringList &names, const QString &projectName)
{
if (names.isEmpty())
return true; // dimension unconstrained
if (projectName.isEmpty())
return false;
// Project names are user-facing identifiers, not paths — case
// sensitive comparison matches what ProjectExplorer hands us.
return names.contains(projectName);
}
} // namespace
bool matches(const AgentConfig::Match &m, const Context &ctx)
{
if (m.isEmpty())
return true; // explicit catch-all
return matchesFilePatterns(m.filePatterns, ctx.filePath)
&& matchesPathPatterns(m.pathPatterns, ctx.filePath)
&& matchesProjectNames(m.projectNames, ctx.projectName);
}
QString pickAgent(
const QStringList &roster, const Context &ctx, const AgentFactory &factory)
{
for (const QString &name : roster) {
const AgentConfig *cfg = factory.configByName(name);
if (!cfg)
continue; // stale roster entry — silently skip
if (matches(cfg->match, ctx))
return name;
}
return {};
}
} // namespace QodeAssist::AgentRouter

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <QStringList>
#include "AgentConfig.hpp"
namespace QodeAssist {
class AgentFactory;
namespace AgentRouter {
struct Context
{
QString filePath;
QString projectName;
};
[[nodiscard]] bool matches(const AgentConfig::Match &match, const Context &ctx);
[[nodiscard]] QString pickAgent(
const QStringList &roster, const Context &ctx, const AgentFactory &factory);
} // namespace AgentRouter
} // namespace QodeAssist

View File

@@ -0,0 +1,30 @@
add_library(Agents STATIC
AgentConfig.hpp
Agent.hpp Agent.cpp
AgentLoader.hpp AgentLoader.cpp
AgentFactory.hpp AgentFactory.cpp
AgentRouter.hpp AgentRouter.cpp
ContextRenderer.hpp ContextRenderer.cpp
agents.qrc
)
target_link_libraries(Agents
PUBLIC
Qt::Core
Qt::Network
QtCreator::Core
QtCreator::Utils
LLMQore
pantor::inja
ProvidersConfig
Common
Providers
Templates
PRIVATE
QodeAssistLogger
tomlplusplus::tomlplusplus
)
target_include_directories(Agents
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@@ -0,0 +1,209 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ContextRenderer.hpp"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStringList>
#include <filesystem>
#include <inja/inja.hpp>
namespace QodeAssist::Templates::ContextRenderer {
namespace {
QString substituteVars(const QString &src, const Bindings &b)
{
QString out = src;
if (!b.projectDir.isEmpty())
out.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir);
if (!b.homeDir.isEmpty())
out.replace(QStringLiteral("${HOME}"), b.homeDir);
return out;
}
bool isPathAllowed(const QString &requestedPath, const Bindings &b)
{
const QString target = QDir::cleanPath(requestedPath);
auto isUnder = [&target](const QString &root) {
if (root.isEmpty()) return false;
const QString cleanRoot = QDir::cleanPath(root);
if (target == cleanRoot) return true;
return target.startsWith(cleanRoot + QLatin1Char('/'));
};
if (isUnder(b.projectDir)) return true;
if (!b.homeDir.isEmpty() && isUnder(b.homeDir + QStringLiteral("/qodeassist")))
return true;
return false;
}
void registerReadFile(inja::Environment &env, const Bindings &b)
{
const Bindings capturedBindings = b;
env.add_callback("read_file", 1, [capturedBindings](inja::Arguments &args) -> nlohmann::json {
const std::string raw = args.at(0)->get<std::string>();
QString path = QString::fromStdString(raw);
if (!capturedBindings.projectDir.isEmpty())
path.replace(QStringLiteral("${PROJECT_DIR}"), capturedBindings.projectDir);
if (!capturedBindings.homeDir.isEmpty())
path.replace(QStringLiteral("${HOME}"), capturedBindings.homeDir);
if (!isPathAllowed(path, capturedBindings)) {
qWarning("[QodeAssist] context.read_file: path not in allowed roots: %s",
qUtf8Printable(path));
return std::string{};
}
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
return std::string{};
return f.readAll().toStdString();
});
}
QString expandAndResolvePath(const QString &raw, const Bindings &b)
{
QString p = raw;
if (!b.projectDir.isEmpty())
p.replace(QStringLiteral("${PROJECT_DIR}"), b.projectDir);
if (!b.homeDir.isEmpty())
p.replace(QStringLiteral("${HOME}"), b.homeDir);
return p;
}
void registerFileExists(inja::Environment &env, const Bindings &b)
{
const Bindings caps = b;
env.add_callback("file_exists", 1, [caps](inja::Arguments &args) -> nlohmann::json {
const QString p = expandAndResolvePath(
QString::fromStdString(args.at(0)->get<std::string>()), caps);
if (!isPathAllowed(p, caps))
return false;
return QFileInfo::exists(p);
});
}
void registerReadDir(inja::Environment &env, const Bindings &b)
{
const Bindings caps = b;
env.add_callback("read_dir", 1, [caps](inja::Arguments &args) -> nlohmann::json {
const QString p = expandAndResolvePath(
QString::fromStdString(args.at(0)->get<std::string>()), caps);
if (!isPathAllowed(p, caps)) {
qWarning("[QodeAssist] context.read_dir: path not in allowed roots: %s",
qUtf8Printable(p));
return nlohmann::json::array();
}
QDir dir(p);
if (!dir.exists())
return nlohmann::json::array();
nlohmann::json out = nlohmann::json::array();
const QStringList entries = dir.entryList(
QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
for (const QString &name : entries)
out.push_back(name.toStdString());
return out;
});
}
void registerStringHelpers(inja::Environment &env)
{
env.add_callback("head_lines", 2, [](inja::Arguments &args) -> nlohmann::json {
const QString text = QString::fromStdString(args.at(0)->get<std::string>());
const int n = args.at(1)->get<int>();
if (n <= 0)
return std::string{};
const QStringList lines = text.split('\n');
const int take = std::min<int>(n, lines.size());
QStringList head;
head.reserve(take);
for (int i = 0; i < take; ++i)
head.append(lines.at(i));
return head.join('\n').toStdString();
});
env.add_callback("basename", 1, [](inja::Arguments &args) -> nlohmann::json {
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
.fileName()
.toStdString();
});
env.add_callback("dirname", 1, [](inja::Arguments &args) -> nlohmann::json {
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
.path()
.toStdString();
});
env.add_callback("ext", 1, [](inja::Arguments &args) -> nlohmann::json {
return QFileInfo(QString::fromStdString(args.at(0)->get<std::string>()))
.suffix()
.toStdString();
});
env.add_callback("lower", 1, [](inja::Arguments &args) -> nlohmann::json {
return QString::fromStdString(args.at(0)->get<std::string>()).toLower().toStdString();
});
env.add_callback("upper", 1, [](inja::Arguments &args) -> nlohmann::json {
return QString::fromStdString(args.at(0)->get<std::string>()).toUpper().toStdString();
});
}
void registerSandbox(inja::Environment &env)
{
env.set_search_included_templates_in_files(false);
env.set_include_callback(
[](const std::filesystem::path &, const std::string &name) -> inja::Template {
throw inja::FileError(
"include is disabled in QodeAssist context: '" + name + "'");
});
env.set_line_statement("@@@inja@@@");
}
} // namespace
QString render(const QString &templateSource, const Bindings &bindings, QString *error)
{
if (templateSource.isEmpty())
return {};
const QString substituted = substituteVars(templateSource, bindings);
inja::Environment env;
registerSandbox(env);
registerReadFile(env, bindings);
registerFileExists(env, bindings);
registerReadDir(env, bindings);
registerStringHelpers(env);
inja::Template tpl;
try {
tpl = env.parse(substituted.toStdString());
} catch (const std::exception &e) {
if (error) {
*error = QStringLiteral("Failed to parse context jinja: %1")
.arg(QString::fromUtf8(e.what()));
}
return {};
}
try {
const std::string rendered = env.render(tpl, nlohmann::json::object());
return QString::fromStdString(rendered);
} catch (const std::exception &e) {
if (error) {
*error = QStringLiteral("Failed to render context jinja: %1")
.arg(QString::fromUtf8(e.what()));
}
return {};
}
}
} // namespace QodeAssist::Templates::ContextRenderer

View File

@@ -0,0 +1,19 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
namespace QodeAssist::Templates::ContextRenderer {
struct Bindings
{
QString projectDir;
QString homeDir;
};
QString render(const QString &templateSource, const Bindings &bindings,
QString *error = nullptr);
} // namespace QodeAssist::Templates::ContextRenderer

View File

@@ -0,0 +1,9 @@
<RCC>
<qresource prefix="/agents">
<file>ollama_base_chat.toml</file>
<file>ollama_base_fim.toml</file>
<file>ollama_gemma4_e4b_chat.toml</file>
<file>ollama_codellama_7b_code_fim.toml</file>
<file>ollama_codellama_13b_qml_fim.toml</file>
</qresource>
</RCC>

View File

@@ -0,0 +1,44 @@
schema_version = 1
name = "Ollama Base Chat"
description = "Shared base for Ollama /api/chat profiles."
abstract = true
provider_instance = "Ollama (Native)"
endpoint = "/api/chat"
tags = ["ollama", "local"]
[template]
message_format = """
{
"messages": [
{%- if existsIn(ctx, "system_prompt") %}
{
"role": "system",
"content": {{ tojson(ctx.system_prompt) }}
}{% if length(ctx.history) > 0 %},{% endif %}
{%- endif %}
{%- for msg in ctx.history %}
{
"role": {{ tojson(msg.role) }},
"content": {{ tojson(msg.content) }}{% if existsIn(msg, "images") %},
"images": [
{%- for img in msg.images %}
{{ tojson(img.data) }}{% if not loop.is_last %},{% endif %}
{%- endfor %}
]{% endif %}
}{% if not loop.is_last %},{% endif %}
{%- endfor %}
]
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 2048
temperature = 0.7
keep_alive = "5m"

View File

@@ -0,0 +1,32 @@
schema_version = 1
name = "Ollama FIM Base"
description = "Shared base for Ollama native FIM (/api/generate) profiles."
abstract = true
provider_instance = "Ollama (Native)"
endpoint = "/api/generate"
tags = ["ollama", "local", "fim"]
[template]
message_format = """
{
"prompt": {{ tojson(ctx.prefix) }},
"suffix": {{ tojson(ctx.suffix) }}
{%- if existsIn(ctx, "system_prompt") %},
"system": {{ tojson(ctx.system_prompt) }}
{%- endif %}
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 512
temperature = 0.2
top_p = 0.9
keep_alive = "5m"
stop = ["<EOT>"]

View File

@@ -0,0 +1,40 @@
schema_version = 1
name = "Qt CodeLlama 13B QML FIM"
description = "Local Qt-Company-tuned CodeLlama 13B for QML FIM completion."
provider_instance = "Ollama (Native)"
endpoint = "/api/generate"
model = "theqtcompany/codellama-13b-qml:latest"
tags = ["fim", "ollama", "local", "codellama", "qml", "qt"]
[match]
file_patterns = ["*.qml"]
[template]
message_format = """
{
"prompt": {%- if existsIn(ctx, "suffix") and length(ctx.suffix) > 0 -%}
{{ tojson("<SUF>" + ctx.suffix + "<PRE>" + ctx.prefix + "<MID>") }}
{%- else -%}
{{ tojson("<PRE>" + ctx.prefix + "<MID>") }}
{%- endif %}
{%- if existsIn(ctx, "system_prompt") %},
"system": {{ tojson(ctx.system_prompt) }}
{%- endif %}
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 500
temperature = 0
top_p = 1
repeat_penalty = 1.05
keep_alive = "5m"
stop = ["<SUF>", "<PRE>", "</PRE>", "</SUF>", "< EOT >", "\\end", "<MID>", "</MID>", "##"]

View File

@@ -0,0 +1,34 @@
schema_version = 1
name = "CodeLlama 7B Code FIM"
description = "Local CodeLlama 7B (code variant) on Ollama, FIM completion via PRE/SUF/MID markers."
provider_instance = "Ollama (Native)"
endpoint = "/api/generate"
model = "codellama:7b-code"
tags = ["fim", "ollama", "local", "codellama"]
[match]
file_patterns = ["*.cpp", "*.cc", "*.cxx", "*.c", "*.h", "*.hpp", "*.hxx", "*.inl"]
[template]
message_format = """
{
"prompt": {{ tojson("<PRE> " + ctx.prefix + " <SUF>" + ctx.suffix + " <MID>") }}
{%- if existsIn(ctx, "system_prompt") %},
"system": {{ tojson(ctx.system_prompt) }}
{%- endif %}
}
"""
[template.sampling]
stream = true
[template.sampling.options]
num_predict = 512
temperature = 0.2
top_p = 0.9
keep_alive = "5m"
stop = ["<EOT>", "<PRE>", "<SUF>", "<MID>"]

View File

@@ -0,0 +1,36 @@
schema_version = 1
name = "Ollama gemma4:e4b Chat"
extends = "Ollama Base Chat"
description = "Local Gemma 4 E4B on Ollama /api/chat — coding chat assistant."
model = "gemma4:e4b"
role = """
You are a helpful coding assistant integrated into Qt Creator.
Answer concisely. When the user shares code, prefer concrete diffs or
minimal patches over rewriting whole files. Use markdown code blocks
with language tags so the IDE can render them.
"""
enable_thinking = true
enable_tools = true
tags = ["chat", "ollama", "local", "gemma"]
context = """
{%- set readme = read_file("${PROJECT_DIR}/README.md") -%}
{%- if length(readme) > 0 %}
## Project README.md
{{ readme }}
{%- endif %}
"""
[template.sampling.options]
num_predict = 4096
temperature = 1
top_k = 64
top_p = 0.95
num_ctx = 8192