mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 02:49:12 -04:00
feat: Add agents and agents settings
This commit is contained in:
94
sources/agents/Agent.cpp
Normal file
94
sources/agents/Agent.cpp
Normal 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
53
sources/agents/Agent.hpp
Normal 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
|
||||
57
sources/agents/AgentConfig.hpp
Normal file
57
sources/agents/AgentConfig.hpp
Normal 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
|
||||
223
sources/agents/AgentFactory.cpp
Normal file
223
sources/agents/AgentFactory.cpp
Normal 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
|
||||
67
sources/agents/AgentFactory.hpp
Normal file
67
sources/agents/AgentFactory.hpp
Normal 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
|
||||
262
sources/agents/AgentLoader.cpp
Normal file
262
sources/agents/AgentLoader.cpp
Normal 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
|
||||
30
sources/agents/AgentLoader.hpp
Normal file
30
sources/agents/AgentLoader.hpp
Normal 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
|
||||
85
sources/agents/AgentRouter.cpp
Normal file
85
sources/agents/AgentRouter.cpp
Normal 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
|
||||
30
sources/agents/AgentRouter.hpp
Normal file
30
sources/agents/AgentRouter.hpp
Normal 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
|
||||
30
sources/agents/CMakeLists.txt
Normal file
30
sources/agents/CMakeLists.txt
Normal 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}
|
||||
)
|
||||
209
sources/agents/ContextRenderer.cpp
Normal file
209
sources/agents/ContextRenderer.cpp
Normal 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
|
||||
19
sources/agents/ContextRenderer.hpp
Normal file
19
sources/agents/ContextRenderer.hpp
Normal 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
|
||||
9
sources/agents/agents.qrc
Normal file
9
sources/agents/agents.qrc
Normal 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>
|
||||
44
sources/agents/ollama_base_chat.toml
Normal file
44
sources/agents/ollama_base_chat.toml
Normal 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"
|
||||
32
sources/agents/ollama_base_fim.toml
Normal file
32
sources/agents/ollama_base_fim.toml
Normal 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>"]
|
||||
40
sources/agents/ollama_codellama_13b_qml_fim.toml
Normal file
40
sources/agents/ollama_codellama_13b_qml_fim.toml
Normal 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>", "##"]
|
||||
34
sources/agents/ollama_codellama_7b_code_fim.toml
Normal file
34
sources/agents/ollama_codellama_7b_code_fim.toml
Normal 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>"]
|
||||
36
sources/agents/ollama_gemma4_e4b_chat.toml
Normal file
36
sources/agents/ollama_gemma4_e4b_chat.toml
Normal 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
|
||||
11
sources/common/CMakeLists.txt
Normal file
11
sources/common/CMakeLists.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
add_library(Common INTERFACE)
|
||||
|
||||
target_sources(Common INTERFACE
|
||||
ContextData.hpp
|
||||
)
|
||||
|
||||
target_include_directories(Common INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
target_link_libraries(Common INTERFACE
|
||||
Qt::Core
|
||||
)
|
||||
79
sources/common/ContextData.hpp
Normal file
79
sources/common/ContextData.hpp
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
#include <optional>
|
||||
|
||||
namespace QodeAssist::Templates {
|
||||
|
||||
struct ContentBlockEntry
|
||||
{
|
||||
enum class Kind {
|
||||
Text,
|
||||
Thinking,
|
||||
RedactedThinking,
|
||||
ToolUse,
|
||||
ToolResult,
|
||||
Image,
|
||||
};
|
||||
|
||||
Kind kind = Kind::Text;
|
||||
|
||||
QString text; // Text
|
||||
QString thinking; // Thinking
|
||||
QString signature; // Thinking / RedactedThinking
|
||||
QString toolUseId; // ToolUse / ToolResult
|
||||
QString toolName; // ToolUse
|
||||
QJsonObject toolInput; // ToolUse
|
||||
QString result; // ToolResult
|
||||
QString imageData; // Image (base64 or url)
|
||||
QString mediaType; // Image
|
||||
bool isImageUrl = false;
|
||||
|
||||
bool operator==(const ContentBlockEntry &) const = default;
|
||||
};
|
||||
|
||||
struct Message
|
||||
{
|
||||
QString role;
|
||||
QVector<ContentBlockEntry> blocks;
|
||||
|
||||
// Convenience for callers that only need a single text block.
|
||||
static Message text(const QString &role, const QString &text)
|
||||
{
|
||||
Message m;
|
||||
m.role = role;
|
||||
ContentBlockEntry e;
|
||||
e.kind = ContentBlockEntry::Kind::Text;
|
||||
e.text = text;
|
||||
m.blocks.append(std::move(e));
|
||||
return m;
|
||||
}
|
||||
|
||||
bool operator==(const Message &) const = default;
|
||||
};
|
||||
|
||||
struct FileMetadata
|
||||
{
|
||||
QString filePath;
|
||||
QString content;
|
||||
bool operator==(const FileMetadata &) const = default;
|
||||
};
|
||||
|
||||
struct ContextData
|
||||
{
|
||||
std::optional<QString> systemPrompt = std::nullopt;
|
||||
std::optional<QString> prefix = std::nullopt;
|
||||
std::optional<QString> suffix = std::nullopt;
|
||||
std::optional<QString> fileContext = std::nullopt;
|
||||
std::optional<QVector<Message>> history = std::nullopt;
|
||||
std::optional<QList<FileMetadata>> filesMetadata = std::nullopt;
|
||||
|
||||
bool operator==(const ContextData &) const = default;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Templates
|
||||
12
sources/external/CMakeLists.txt
vendored
12
sources/external/CMakeLists.txt
vendored
@@ -1,5 +1,17 @@
|
||||
include(FetchContent)
|
||||
|
||||
set(INJA_BUILD_TESTS OFF CACHE INTERNAL "")
|
||||
set(INJA_INSTALL OFF CACHE INTERNAL "")
|
||||
set(INJA_EXPORT OFF CACHE INTERNAL "")
|
||||
set(BUILD_BENCHMARK OFF CACHE INTERNAL "")
|
||||
set(COVERALLS OFF CACHE INTERNAL "")
|
||||
FetchContent_Declare(inja
|
||||
GIT_REPOSITORY https://github.com/pantor/inja.git
|
||||
GIT_TAG v3.5.0
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(inja)
|
||||
|
||||
FetchContent_Declare(tomlplusplus
|
||||
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
||||
GIT_TAG v3.4.0
|
||||
|
||||
22
sources/providers/CMakeLists.txt
Normal file
22
sources/providers/CMakeLists.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
add_library(Providers STATIC
|
||||
ProviderID.hpp
|
||||
Provider.hpp Provider.cpp
|
||||
ProviderFactory.hpp ProviderFactory.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(Providers
|
||||
PUBLIC
|
||||
Qt::Core
|
||||
Qt::Network
|
||||
QtCreator::Core
|
||||
QtCreator::Utils
|
||||
LLMQore
|
||||
Common
|
||||
PRIVATE
|
||||
QodeAssistLogger
|
||||
)
|
||||
|
||||
target_include_directories(Providers
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/sources/templates
|
||||
)
|
||||
86
sources/providers/Provider.cpp
Normal file
86
sources/providers/Provider.cpp
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "Provider.hpp"
|
||||
|
||||
#include "PromptTemplate.hpp"
|
||||
|
||||
#include <LLMQore/BaseClient.hpp>
|
||||
#include <LLMQore/ToolsManager.hpp>
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
|
||||
#include <Logger.hpp>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
Provider::Provider(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
bool Provider::prepareRequest(
|
||||
QJsonObject &request,
|
||||
PromptTemplate *prompt,
|
||||
const ContextData &context,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled)
|
||||
{
|
||||
if (!prompt) {
|
||||
LOG_MESSAGE(QString("Provider '%1': null template").arg(name()));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!prompt->isSupportProvider(providerID())) {
|
||||
LOG_MESSAGE(QString("Template '%1' doesn't support provider '%2'")
|
||||
.arg(prompt->name(), name()));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!prompt->buildFullRequest(request, context, isThinkingEnabled)) {
|
||||
LOG_MESSAGE(
|
||||
QString("Provider '%1': template '%2' failed to build request")
|
||||
.arg(name(), prompt->name()));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isToolsEnabled) {
|
||||
const auto toolsDefinitions = toolsManager()->getToolsDefinitions();
|
||||
if (!toolsDefinitions.isEmpty()) {
|
||||
request["tools"] = toolsDefinitions;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
RequestID Provider::sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
|
||||
{
|
||||
auto *c = client();
|
||||
|
||||
c->setUrl(url.toString());
|
||||
c->setApiKey(apiKey());
|
||||
|
||||
auto requestId = c->sendMessage(payload, endpoint);
|
||||
|
||||
LOG_MESSAGE(
|
||||
QString("%1: Sending request %2 to %3%4").arg(name(), requestId, url.toString(), endpoint));
|
||||
LOG_MESSAGE(
|
||||
QString("%1: Payload:\n%2")
|
||||
.arg(name(), QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Indented))));
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
void Provider::cancelRequest(const RequestID &requestId)
|
||||
{
|
||||
LOG_MESSAGE(QString("%1: Cancelling request %2").arg(name(), requestId));
|
||||
client()->cancelRequest(requestId);
|
||||
}
|
||||
|
||||
::LLMQore::ToolsManager *Provider::toolsManager() const
|
||||
{
|
||||
return client()->tools();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
80
sources/providers/Provider.hpp
Normal file
80
sources/providers/Provider.hpp
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QFlags>
|
||||
#include <QFuture>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <utils/environment.h>
|
||||
|
||||
#include "ContextData.hpp"
|
||||
#include "ProviderID.hpp"
|
||||
#include "LLMQore/BaseClient.hpp"
|
||||
|
||||
namespace LLMQore {
|
||||
class BaseClient;
|
||||
class ToolsManager;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Templates {
|
||||
class PromptTemplate;
|
||||
}
|
||||
|
||||
class QJsonObject;
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
using Templates::ContextData;
|
||||
using Templates::PromptTemplate;
|
||||
using LLMQore::RequestID;
|
||||
|
||||
enum class ProviderCapability {
|
||||
Tools = 0x1,
|
||||
Thinking = 0x2,
|
||||
Image = 0x4,
|
||||
ModelListing = 0x8,
|
||||
};
|
||||
Q_DECLARE_FLAGS(ProviderCapabilities, ProviderCapability)
|
||||
Q_DECLARE_OPERATORS_FOR_FLAGS(ProviderCapabilities)
|
||||
|
||||
class Provider : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_DISABLE_COPY_MOVE(Provider)
|
||||
public:
|
||||
explicit Provider(QObject *parent = nullptr);
|
||||
|
||||
virtual ~Provider() = default;
|
||||
|
||||
virtual QString name() const = 0;
|
||||
|
||||
virtual QString url() const { return m_url; }
|
||||
virtual QString apiKey() const { return m_apiKey; }
|
||||
void setUrl(const QString &url) { m_url = url; }
|
||||
void setApiKey(const QString &apiKey) { m_apiKey = apiKey; }
|
||||
|
||||
[[nodiscard]] virtual bool prepareRequest(
|
||||
QJsonObject &request,
|
||||
PromptTemplate *prompt,
|
||||
const ContextData &context,
|
||||
bool isToolsEnabled,
|
||||
bool isThinkingEnabled);
|
||||
virtual QFuture<QList<QString>> getInstalledModels(const QString &url) = 0;
|
||||
virtual ProviderID providerID() const = 0;
|
||||
virtual ProviderCapabilities capabilities() const { return {}; }
|
||||
|
||||
virtual ::LLMQore::BaseClient *client() const = 0;
|
||||
|
||||
virtual RequestID sendRequest(
|
||||
const QUrl &url, const QJsonObject &payload, const QString &endpoint);
|
||||
void cancelRequest(const RequestID &requestId);
|
||||
::LLMQore::ToolsManager *toolsManager() const;
|
||||
|
||||
private:
|
||||
QString m_url;
|
||||
QString m_apiKey;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
43
sources/providers/ProviderFactory.cpp
Normal file
43
sources/providers/ProviderFactory.cpp
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "ProviderFactory.hpp"
|
||||
|
||||
#include <QHash>
|
||||
|
||||
namespace QodeAssist::Providers::ProviderFactory {
|
||||
|
||||
namespace {
|
||||
|
||||
QHash<QString, FactoryFn> &table()
|
||||
{
|
||||
static QHash<QString, FactoryFn> t;
|
||||
return t;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void registerType(const QString &name, FactoryFn fn)
|
||||
{
|
||||
if (name.isEmpty() || !fn) return;
|
||||
table().insert(name, std::move(fn));
|
||||
}
|
||||
|
||||
Provider *create(const QString &name, QObject *parent)
|
||||
{
|
||||
auto it = table().constFind(name);
|
||||
if (it == table().constEnd()) return nullptr;
|
||||
return it.value()(parent);
|
||||
}
|
||||
|
||||
QStringList knownNames()
|
||||
{
|
||||
return table().keys();
|
||||
}
|
||||
|
||||
void clear()
|
||||
{
|
||||
table().clear();
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Providers::ProviderFactory
|
||||
25
sources/providers/ProviderFactory.hpp
Normal file
25
sources/providers/ProviderFactory.hpp
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
class Provider;
|
||||
|
||||
namespace ProviderFactory {
|
||||
|
||||
using FactoryFn = std::function<Provider *(QObject *parent)>;
|
||||
|
||||
void registerType(const QString &name, FactoryFn fn);
|
||||
Provider *create(const QString &name, QObject *parent);
|
||||
QStringList knownNames();
|
||||
void clear(); // for tests / shutdown
|
||||
|
||||
} // namespace ProviderFactory
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
22
sources/providers/ProviderID.hpp
Normal file
22
sources/providers/ProviderID.hpp
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
|
||||
enum class ProviderID : int {
|
||||
Any,
|
||||
Ollama,
|
||||
LMStudio,
|
||||
Claude,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenAIResponses,
|
||||
MistralAI,
|
||||
OpenRouter,
|
||||
GoogleAI,
|
||||
LlamaCpp,
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Providers
|
||||
17
sources/templates/CMakeLists.txt
Normal file
17
sources/templates/CMakeLists.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
add_library(Templates STATIC
|
||||
PromptTemplate.hpp
|
||||
JsonPromptTemplate.hpp JsonPromptTemplate.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(Templates
|
||||
PUBLIC
|
||||
Qt::Core
|
||||
Common
|
||||
Providers
|
||||
pantor::inja
|
||||
)
|
||||
|
||||
target_include_directories(Templates
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/sources/agents
|
||||
)
|
||||
336
sources/templates/JsonPromptTemplate.cpp
Normal file
336
sources/templates/JsonPromptTemplate.cpp
Normal file
@@ -0,0 +1,336 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "JsonPromptTemplate.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "AgentConfig.hpp"
|
||||
|
||||
namespace QodeAssist::Templates {
|
||||
|
||||
namespace {
|
||||
|
||||
nlohmann::json buildContextJson(const ContextData &context)
|
||||
{
|
||||
nlohmann::json ctx = nlohmann::json::object();
|
||||
|
||||
if (context.systemPrompt) {
|
||||
ctx["system_prompt"] = context.systemPrompt->toStdString();
|
||||
}
|
||||
|
||||
if (context.prefix) {
|
||||
ctx["prefix"] = context.prefix->toStdString();
|
||||
}
|
||||
if (context.suffix) {
|
||||
ctx["suffix"] = context.suffix->toStdString();
|
||||
}
|
||||
|
||||
if (context.filesMetadata && !context.filesMetadata->isEmpty()) {
|
||||
nlohmann::json files = nlohmann::json::array();
|
||||
for (const auto &file : context.filesMetadata.value()) {
|
||||
nlohmann::json fj = nlohmann::json::object();
|
||||
fj["file_path"] = file.filePath.toStdString();
|
||||
fj["content"] = file.content.toStdString();
|
||||
files.push_back(std::move(fj));
|
||||
}
|
||||
ctx["files_metadata"] = std::move(files);
|
||||
}
|
||||
|
||||
nlohmann::json history = nlohmann::json::array();
|
||||
if (context.history) {
|
||||
for (const auto &msg : context.history.value()) {
|
||||
nlohmann::json mj = nlohmann::json::object();
|
||||
mj["role"] = msg.role.toStdString();
|
||||
|
||||
nlohmann::json blocks = nlohmann::json::array();
|
||||
QString flatContent;
|
||||
nlohmann::json flatImages = nlohmann::json::array();
|
||||
|
||||
for (const auto &b : msg.blocks) {
|
||||
nlohmann::json bj = nlohmann::json::object();
|
||||
switch (b.kind) {
|
||||
case ContentBlockEntry::Kind::Text:
|
||||
bj["type"] = "text";
|
||||
bj["text"] = b.text.toStdString();
|
||||
flatContent += b.text;
|
||||
break;
|
||||
case ContentBlockEntry::Kind::Thinking:
|
||||
bj["type"] = "thinking";
|
||||
bj["thinking"] = b.thinking.toStdString();
|
||||
bj["signature"] = b.signature.toStdString();
|
||||
break;
|
||||
case ContentBlockEntry::Kind::RedactedThinking:
|
||||
bj["type"] = "redacted_thinking";
|
||||
bj["data"] = b.signature.toStdString();
|
||||
break;
|
||||
case ContentBlockEntry::Kind::ToolUse: {
|
||||
bj["type"] = "tool_use";
|
||||
bj["id"] = b.toolUseId.toStdString();
|
||||
bj["name"] = b.toolName.toStdString();
|
||||
const std::string inputStr
|
||||
= QJsonDocument(b.toolInput).toJson(QJsonDocument::Compact).toStdString();
|
||||
nlohmann::json parsedInput
|
||||
= nlohmann::json::parse(inputStr, nullptr, /*allow_exceptions=*/false);
|
||||
if (parsedInput.is_discarded()) {
|
||||
if (!b.toolInput.isEmpty()) {
|
||||
qWarning("[QodeAssist] tool_use '%s' has unparseable input "
|
||||
"(serialized as null): %s",
|
||||
qUtf8Printable(b.toolName),
|
||||
inputStr.c_str());
|
||||
}
|
||||
parsedInput = nullptr;
|
||||
}
|
||||
bj["input"] = std::move(parsedInput);
|
||||
break;
|
||||
}
|
||||
case ContentBlockEntry::Kind::ToolResult:
|
||||
bj["type"] = "tool_result";
|
||||
bj["tool_use_id"] = b.toolUseId.toStdString();
|
||||
bj["content"] = b.result.toStdString();
|
||||
break;
|
||||
case ContentBlockEntry::Kind::Image:
|
||||
bj["type"] = "image";
|
||||
bj["data"] = b.imageData.toStdString();
|
||||
bj["media_type"] = b.mediaType.toStdString();
|
||||
bj["is_url"] = b.isImageUrl;
|
||||
{
|
||||
nlohmann::json ij = nlohmann::json::object();
|
||||
ij["data"] = b.imageData.toStdString();
|
||||
ij["media_type"] = b.mediaType.toStdString();
|
||||
ij["is_url"] = b.isImageUrl;
|
||||
flatImages.push_back(std::move(ij));
|
||||
}
|
||||
break;
|
||||
}
|
||||
blocks.push_back(std::move(bj));
|
||||
}
|
||||
|
||||
mj["content"] = flatContent.toStdString();
|
||||
if (!flatImages.empty())
|
||||
mj["images"] = std::move(flatImages);
|
||||
mj["content_blocks"] = std::move(blocks);
|
||||
|
||||
history.push_back(std::move(mj));
|
||||
}
|
||||
}
|
||||
ctx["history"] = std::move(history);
|
||||
|
||||
nlohmann::json data = nlohmann::json::object();
|
||||
data["ctx"] = std::move(ctx);
|
||||
return data;
|
||||
}
|
||||
|
||||
void registerStandardCallbacks(inja::Environment &env)
|
||||
{
|
||||
// Sandbox: disable filesystem reads from `{% include %}` and reject
|
||||
// any include callback. User-authored templates run with full
|
||||
// process privileges, so they must not slurp arbitrary files via
|
||||
// include directives. File reads happen only through
|
||||
// ContextManager-provided callbacks (e.g. read_file()).
|
||||
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 templates: '" + name + "'");
|
||||
});
|
||||
|
||||
// Disable inja's `##` line-statement shorthand — collides with
|
||||
// Markdown headings inside template bodies. Same rationale as in
|
||||
// ContextRenderer; retarget to an unreachable sentinel.
|
||||
env.set_line_statement("@@@inja@@@");
|
||||
|
||||
env.add_callback("tojson", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||
return args.at(0)->dump();
|
||||
});
|
||||
|
||||
env.add_callback("strip_signature_suffix", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||
std::string content = args.at(0)->get<std::string>();
|
||||
const std::string marker = "\n[Signature: ";
|
||||
const auto pos = content.find(marker);
|
||||
if (pos != std::string::npos) {
|
||||
content = content.substr(0, pos);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
env.add_callback("filter_skip_role", 2, [](inja::Arguments &args) -> nlohmann::json {
|
||||
const nlohmann::json &history = *args.at(0);
|
||||
const std::string role = args.at(1)->get<std::string>();
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (const auto &msg : history) {
|
||||
if (msg.contains("role") && msg["role"].get<std::string>() == role) {
|
||||
continue;
|
||||
}
|
||||
result.push_back(msg);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
env.add_callback("filter_skip_empty_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||
const nlohmann::json &history = *args.at(0);
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (const auto &msg : history) {
|
||||
const bool isThinking = msg.value("is_thinking", false);
|
||||
const std::string sig = msg.value("signature", "");
|
||||
if (isThinking && sig.empty()) {
|
||||
continue;
|
||||
}
|
||||
result.push_back(msg);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
env.add_callback(
|
||||
"filter_skip_empty_parts_thinking", 1, [](inja::Arguments &args) -> nlohmann::json {
|
||||
const nlohmann::json &history = *args.at(0);
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (const auto &msg : history) {
|
||||
const bool isThinking = msg.value("is_thinking", false);
|
||||
const std::string content = msg.value("content", "");
|
||||
const std::string sig = msg.value("signature", "");
|
||||
if (isThinking && content.empty() && sig.empty()) {
|
||||
continue;
|
||||
}
|
||||
result.push_back(msg);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
|
||||
const AgentConfig &cfg, QString *error)
|
||||
{
|
||||
auto setError = [&error](const QString &msg) {
|
||||
if (error) *error = msg;
|
||||
};
|
||||
|
||||
if (cfg.messageFormat.isEmpty()) {
|
||||
setError(QStringLiteral("Agent '%1' has empty message_format").arg(cfg.name));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto tpl = std::unique_ptr<JsonPromptTemplate>(new JsonPromptTemplate);
|
||||
tpl->m_name = cfg.name;
|
||||
tpl->m_description = cfg.description;
|
||||
tpl->m_sampling = cfg.sampling;
|
||||
tpl->m_thinking = cfg.thinking;
|
||||
|
||||
registerStandardCallbacks(tpl->m_env);
|
||||
try {
|
||||
tpl->m_template = tpl->m_env.parse(cfg.messageFormat.toStdString());
|
||||
} catch (const std::exception &e) {
|
||||
setError(QStringLiteral("Failed to parse jinja for '%1': %2")
|
||||
.arg(cfg.name, QString::fromUtf8(e.what())));
|
||||
return nullptr;
|
||||
}
|
||||
return tpl;
|
||||
}
|
||||
|
||||
std::optional<QJsonObject> JsonPromptTemplate::renderBody(const ContextData &context) const
|
||||
{
|
||||
const nlohmann::json data = buildContextJson(context);
|
||||
|
||||
std::string rendered;
|
||||
try {
|
||||
std::lock_guard<std::mutex> lock(m_renderMutex);
|
||||
rendered = m_env.render(m_template, data);
|
||||
} catch (const std::exception &e) {
|
||||
qWarning("[QodeAssist] Template '%s' render failed: %s",
|
||||
qUtf8Printable(m_name),
|
||||
e.what());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc
|
||||
= QJsonDocument::fromJson(QByteArray::fromStdString(rendered), &err);
|
||||
constexpr std::size_t kMaxRenderedLogChars = 500;
|
||||
const std::string truncated = rendered.size() > kMaxRenderedLogChars
|
||||
? rendered.substr(0, kMaxRenderedLogChars) + "... [truncated]"
|
||||
: rendered;
|
||||
if (err.error != QJsonParseError::NoError) {
|
||||
qWarning("[QodeAssist] Template '%s' produced invalid JSON at offset %d: %s\n"
|
||||
"--- raw output (truncated) ---\n%s",
|
||||
qUtf8Printable(m_name),
|
||||
err.offset,
|
||||
qUtf8Printable(err.errorString()),
|
||||
truncated.c_str());
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!doc.isObject()) {
|
||||
qWarning("[QodeAssist] Template '%s' rendered a non-object JSON value (truncated):\n%s",
|
||||
qUtf8Printable(m_name),
|
||||
truncated.c_str());
|
||||
return std::nullopt;
|
||||
}
|
||||
return doc.object();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool mergeRenderedBody(QJsonObject &request, const std::optional<QJsonObject> &body)
|
||||
{
|
||||
if (!body)
|
||||
return false;
|
||||
for (auto it = body->constBegin(); it != body->constEnd(); ++it) {
|
||||
request.insert(it.key(), it.value());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void deepMergeInto(QJsonObject &base, const QJsonObject &overlay)
|
||||
{
|
||||
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
|
||||
const QJsonValue baseVal = base.value(it.key());
|
||||
const QJsonValue overlayVal = it.value();
|
||||
if (baseVal.isObject() && overlayVal.isObject()) {
|
||||
QJsonObject merged = baseVal.toObject();
|
||||
deepMergeInto(merged, overlayVal.toObject());
|
||||
base[it.key()] = merged;
|
||||
} else {
|
||||
base[it.key()] = overlayVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const
|
||||
{
|
||||
mergeRenderedBody(request, renderBody(context));
|
||||
}
|
||||
|
||||
bool JsonPromptTemplate::buildFullRequest(
|
||||
QJsonObject &request,
|
||||
const ContextData &context,
|
||||
bool thinkingEnabled) const
|
||||
{
|
||||
if (!mergeRenderedBody(request, renderBody(context)))
|
||||
return false;
|
||||
applySampling(request, thinkingEnabled);
|
||||
return true;
|
||||
}
|
||||
|
||||
void JsonPromptTemplate::applySampling(QJsonObject &request, bool thinkingEnabled) const
|
||||
{
|
||||
// Merge order: sampling provides defaults → body wins for its own
|
||||
// keys → thinking overrides win on top.
|
||||
QJsonObject merged = m_sampling;
|
||||
deepMergeInto(merged, request);
|
||||
|
||||
if (thinkingEnabled && !m_thinking.isEmpty()) {
|
||||
deepMergeInto(merged, m_thinking.value("overrides").toObject());
|
||||
deepMergeInto(merged, m_thinking.value("request_block").toObject());
|
||||
}
|
||||
|
||||
request = std::move(merged);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Templates
|
||||
75
sources/templates/JsonPromptTemplate.hpp
Normal file
75
sources/templates/JsonPromptTemplate.hpp
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <inja/inja.hpp>
|
||||
|
||||
#include "PromptTemplate.hpp"
|
||||
|
||||
namespace QodeAssist {
|
||||
struct AgentConfig;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Templates {
|
||||
|
||||
// Renderer for the request-body jinja template embedded in an
|
||||
// AgentConfig. One per Agent — built inline from the config (no shared
|
||||
// template registry, no model/provider filtering).
|
||||
class JsonPromptTemplate : public PromptTemplate
|
||||
{
|
||||
public:
|
||||
// Build a renderer from an already-parsed agent config. Compiles
|
||||
// the jinja source via inja once. On failure returns nullptr and
|
||||
// populates `*error` (existing content preserved; pass nullptr to
|
||||
// discard).
|
||||
static std::unique_ptr<JsonPromptTemplate> fromConfig(
|
||||
const AgentConfig &cfg, QString *error = nullptr);
|
||||
|
||||
QString name() const override { return m_name; }
|
||||
QString description() const override { return m_description; }
|
||||
|
||||
// Standalone-template filters are gone — each template is built for
|
||||
// exactly one agent, so it always matches its owner's provider/model.
|
||||
bool isSupportProvider(Providers::ProviderID) const override { return true; }
|
||||
bool isSupportModel(const QString &) const override { return true; }
|
||||
PromptShape shape() const override { return PromptShape::Chat; }
|
||||
|
||||
void prepareRequest(QJsonObject &request, const ContextData &context) const override;
|
||||
|
||||
[[nodiscard]] bool buildFullRequest(
|
||||
QJsonObject &request,
|
||||
const ContextData &context,
|
||||
bool thinkingEnabled = false) const override;
|
||||
|
||||
const QJsonObject &sampling() const { return m_sampling; }
|
||||
|
||||
private:
|
||||
JsonPromptTemplate() = default;
|
||||
|
||||
std::optional<QJsonObject> renderBody(const ContextData &context) const;
|
||||
void applySampling(QJsonObject &request, bool thinkingEnabled) const;
|
||||
|
||||
QString m_name;
|
||||
QString m_description;
|
||||
|
||||
// m_env is populated once in fromConfig() and never mutated again.
|
||||
// It is `mutable` only because inja::Environment::render() is not a
|
||||
// const member; m_renderMutex serialises those render() calls since
|
||||
// inja's render path is not internally re-entrant on one Environment.
|
||||
mutable inja::Environment m_env;
|
||||
inja::Template m_template;
|
||||
mutable std::mutex m_renderMutex;
|
||||
|
||||
QJsonObject m_sampling;
|
||||
QJsonObject m_thinking;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Templates
|
||||
50
sources/templates/PromptTemplate.hpp
Normal file
50
sources/templates/PromptTemplate.hpp
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
#include "ContextData.hpp"
|
||||
#include "ProviderID.hpp"
|
||||
|
||||
namespace QodeAssist::Templates {
|
||||
|
||||
using Providers::ProviderID;
|
||||
|
||||
enum class PromptShape {
|
||||
Chat,
|
||||
Fim,
|
||||
};
|
||||
|
||||
class PromptTemplate
|
||||
{
|
||||
public:
|
||||
PromptTemplate() = default;
|
||||
virtual ~PromptTemplate() = default;
|
||||
|
||||
PromptTemplate(const PromptTemplate &) = delete;
|
||||
PromptTemplate &operator=(const PromptTemplate &) = delete;
|
||||
PromptTemplate(PromptTemplate &&) = delete;
|
||||
PromptTemplate &operator=(PromptTemplate &&) = delete;
|
||||
|
||||
virtual QString name() const = 0;
|
||||
virtual void prepareRequest(QJsonObject &request, const ContextData &context) const = 0;
|
||||
virtual QString description() const = 0;
|
||||
virtual bool isSupportProvider(ProviderID id) const = 0;
|
||||
virtual PromptShape shape() const { return PromptShape::Chat; }
|
||||
|
||||
virtual bool isSupportModel(const QString & /*modelName*/) const { return true; }
|
||||
|
||||
[[nodiscard]] virtual bool buildFullRequest(
|
||||
QJsonObject &request,
|
||||
const ContextData &context,
|
||||
bool /*thinkingEnabled*/ = false) const
|
||||
{
|
||||
prepareRequest(request, context);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
} // namespace QodeAssist::Templates
|
||||
Reference in New Issue
Block a user