fix: Found and fix review mistakes

This commit is contained in:
Petr Mironychev
2026-07-02 22:26:07 +02:00
parent c070d65366
commit 35bbaa1af0
139 changed files with 2032 additions and 1573 deletions

View File

@@ -22,9 +22,12 @@ Q_LOGGING_CATEGORY(ctxLog, "qodeassist.context")
QString roleToWireString(Message::Role role)
{
switch (role) {
case Message::Role::System: return QStringLiteral("system");
case Message::Role::User: return QStringLiteral("user");
case Message::Role::Assistant: return QStringLiteral("assistant");
case Message::Role::System:
return QStringLiteral("system");
case Message::Role::User:
return QStringLiteral("user");
case Message::Role::Assistant:
return QStringLiteral("assistant");
}
return QStringLiteral("user");
}
@@ -70,7 +73,9 @@ QString Manifest::summary() const
if (unsupportedBlocks > 0)
s += QStringLiteral(", unsupported=%1").arg(unsupportedBlocks);
if (!elided.isEmpty())
s += QStringLiteral(", elided=%1 [%2]").arg(elided.size()).arg(elided.join(QStringLiteral("; ")));
s += QStringLiteral(", elided=%1 [%2]")
.arg(elided.size())
.arg(elided.join(QStringLiteral("; ")));
return s;
}
@@ -135,8 +140,7 @@ Templates::ContextData assemble(
e.kind = ContentBlockEntry::Kind::Image;
e.imageData = img->data();
e.mediaType = img->mediaType();
e.isImageUrl
= (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url);
e.isImageUrl = (img->sourceType() == LLMQore::ImageContent::ImageSourceType::Url);
blockEntries.append(std::move(e));
++manifest.imageBlocks;
} else if (auto *si = dynamic_cast<StoredImageContent *>(block)) {
@@ -144,8 +148,7 @@ Templates::ContextData assemble(
if (base64.isEmpty()) {
blockEntries.append(
makeTextEntry(placeholderFor(QStringLiteral("Image"), si->fileName())));
manifest.elided
<< QStringLiteral("image unavailable: %1").arg(si->fileName());
manifest.elided << QStringLiteral("image unavailable: %1").arg(si->fileName());
qCWarning(ctxLog).noquote()
<< "stored image unavailable, placeholder inserted:" << si->fileName();
continue;
@@ -165,8 +168,7 @@ Templates::ContextData assemble(
manifest.elided
<< QStringLiteral("attachment unavailable: %1").arg(sa->fileName());
qCWarning(ctxLog).noquote()
<< "stored attachment unavailable, placeholder inserted:"
<< sa->fileName();
<< "stored attachment unavailable, placeholder inserted:" << sa->fileName();
continue;
}
const QString text = QString::fromUtf8(QByteArray::fromBase64(stored.toUtf8()));
@@ -199,8 +201,7 @@ Templates::ContextData assemble(
blockEntries.append(std::move(e));
} else if (auto *tu = dynamic_cast<LLMQore::ToolUseContent *>(block)) {
if (!resolvedToolUseIds.contains(tu->id())) {
manifest.elided
<< QStringLiteral("orphan tool_use dropped: %1").arg(tu->id());
manifest.elided << QStringLiteral("orphan tool_use dropped: %1").arg(tu->id());
continue;
}
ContentBlockEntry e;
@@ -233,11 +234,11 @@ Templates::ContextData assemble(
if (blockEntries.isEmpty())
continue;
const bool hasNonThinking = std::any_of(
blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) {
return e.kind != ContentBlockEntry::Kind::Thinking
&& e.kind != ContentBlockEntry::Kind::RedactedThinking;
});
const bool hasNonThinking
= std::any_of(blockEntries.begin(), blockEntries.end(), [](const ContentBlockEntry &e) {
return e.kind != ContentBlockEntry::Kind::Thinking
&& e.kind != ContentBlockEntry::Kind::RedactedThinking;
});
if (!hasNonThinking) {
manifest.elided << QStringLiteral("thinking-only message dropped");
continue;

View File

@@ -33,7 +33,7 @@ void ConversationHistory::appendTextDeltaToLast(const QString &delta)
return;
auto &last = m_messages.back();
if (auto *text = last.lastBlockOfType<LLMQore::TextContent>()) {
if (auto *text = dynamic_cast<LLMQore::TextContent *>(last.lastBlock())) {
text->appendText(delta);
} else {
last.appendBlock(std::make_unique<LLMQore::TextContent>(delta));
@@ -41,22 +41,16 @@ void ConversationHistory::appendTextDeltaToLast(const QString &delta)
emit messageUpdated(static_cast<int>(m_messages.size()) - 1);
}
void ConversationHistory::appendThinkingDeltaToLast(const QString &delta, const QString &signature)
void ConversationHistory::appendThinkingBlockToLast(const QString &thinking, const QString &signature)
{
if (m_messages.empty() || (delta.isEmpty() && signature.isEmpty()))
if (m_messages.empty() || (thinking.isEmpty() && signature.isEmpty()))
return;
auto &last = m_messages.back();
auto *thinking = last.lastBlockOfType<LLMQore::ThinkingContent>();
if (!thinking) {
auto fresh = std::make_unique<LLMQore::ThinkingContent>(delta, signature);
last.appendBlock(std::move(fresh));
} else {
if (!delta.isEmpty())
thinking->appendThinking(delta);
if (!signature.isEmpty())
thinking->setSignature(signature);
}
if (thinking.isEmpty())
last.appendBlock(std::make_unique<LLMQore::RedactedThinkingContent>(signature));
else
last.appendBlock(std::make_unique<LLMQore::ThinkingContent>(thinking, signature));
emit messageUpdated(static_cast<int>(m_messages.size()) - 1);
}

View File

@@ -32,7 +32,7 @@ public:
void appendBlockToLast(std::unique_ptr<LLMQore::ContentBlock> block);
void appendTextDeltaToLast(const QString &delta);
void appendThinkingDeltaToLast(const QString &delta, const QString &signature = QString());
void appendThinkingBlockToLast(const QString &thinking, const QString &signature = QString());
void clear();
void resetTo(int index);

View File

@@ -43,14 +43,14 @@ struct ErrorInfo
return text.contains(QLatin1String(needle));
};
if (contains("401") || contains("403") || contains("unauthorized")
|| contains("forbidden") || contains("api key") || contains("apikey")
|| contains("authentication") || contains("invalid token"))
if (contains("401") || contains("403") || contains("unauthorized") || contains("forbidden")
|| contains("api key") || contains("apikey") || contains("authentication")
|| contains("invalid token"))
return ErrorCategory::Auth;
if (contains("timeout") || contains("timed out") || contains("connection")
|| contains("could not resolve") || contains("unreachable")
|| contains("network") || contains("ssl") || contains("refused"))
|| contains("could not resolve") || contains("unreachable") || contains("network")
|| contains("ssl") || contains("refused"))
return ErrorCategory::Network;
return ErrorCategory::Provider;

View File

@@ -45,6 +45,16 @@ public:
m_blocks.push_back(std::move(block));
}
LLMQore::ContentBlock *lastBlock() noexcept
{
return m_blocks.empty() ? nullptr : m_blocks.back().get();
}
std::vector<std::unique_ptr<LLMQore::ContentBlock>> takeBlocks() noexcept
{
return std::move(m_blocks);
}
template<typename T>
T *lastBlockOfType()
{

View File

@@ -165,7 +165,7 @@ std::unique_ptr<LLMQore::ContentBlock> blockFromJson(const QJsonObject &obj)
FileEditContent::statusFromString(obj.value("status").toString()),
obj.value("statusMessage").toString());
}
return nullptr; // unknown type — skipped
return nullptr;
}
} // namespace
@@ -179,8 +179,11 @@ QJsonObject MessageSerializer::toJson(const Message &message)
QJsonArray blocks;
for (const auto &b : message.blocks()) {
if (b)
blocks.append(blockToJson(*b));
if (!b)
continue;
const QJsonObject blockObj = blockToJson(*b);
if (!blockObj.isEmpty())
blocks.append(blockObj);
}
obj["blocks"] = blocks;
return obj;

View File

@@ -144,8 +144,7 @@ public:
ResponseEvents::Usage{inputTokens, outputTokens, cachedTokens, reasoningTokens}};
}
static ResponseEvent error(
QString message, ErrorCategory category = ErrorCategory::Provider)
static ResponseEvent error(QString message, ErrorCategory category = ErrorCategory::Provider)
{
return {Kind::Error, ResponseEvents::Error{std::move(message), category}};
}

View File

@@ -102,7 +102,7 @@ void ResponseRouter::onThinking(
return;
ensureAssistantOpen();
if (m_history)
m_history->appendThinkingDeltaToLast(thinking, signature);
m_history->appendThinkingBlockToLast(thinking, signature);
emit event(ResponseEvent::thinkingDelta(thinking, signature));
}
@@ -153,11 +153,12 @@ void ResponseRouter::onFinalized(
if (id != m_activeId)
return;
if (info.usage) {
emit event(ResponseEvent::usage(
info.usage->promptTokens,
info.usage->completionTokens,
info.usage->cachedPromptTokens,
info.usage->reasoningTokens));
emit event(
ResponseEvent::usage(
info.usage->promptTokens,
info.usage->completionTokens,
info.usage->cachedPromptTokens,
info.usage->reasoningTokens));
}
emit event(ResponseEvent::messageStop(info.stopReason));
endRequest();

View File

@@ -28,13 +28,14 @@ namespace {
} // namespace
Session::Session(Agent *agent, QObject *parent)
: Session(agent, /*externalHistory=*/nullptr, parent)
: Session(agent, nullptr, parent)
{}
Session::Session(Agent *agent, ConversationHistory *externalHistory, QObject *parent)
: QObject(parent)
, m_history(externalHistory ? externalHistory : new ConversationHistory(this))
, m_systemPrompt(new SystemPromptBuilder(this))
, m_externalHistory(externalHistory != nullptr)
{
if (agent)
setAgent(agent);
@@ -45,15 +46,16 @@ void Session::setAgent(Agent *agent)
if (agent == m_agent)
return;
if (isInFlight())
teardownInFlight();
cancel();
if (m_router) {
delete m_router;
m_router->disconnect(this);
m_router->deleteLater();
m_router = nullptr;
}
delete m_agent;
if (m_agent)
m_agent->deleteLater();
m_agent = agent;
m_invalidReason.clear();
@@ -84,8 +86,7 @@ void Session::setAgent(Agent *agent)
Session::~Session()
{
if (isInFlight())
teardownInFlight();
cancel();
}
bool Session::isValid() const noexcept
@@ -154,6 +155,11 @@ void Session::unpinContext(const QString &id)
std::erase_if(m_pinnedProviders, [&id](const auto &entry) { return entry.first == id; });
}
void Session::clearPinnedContext()
{
m_pinnedProviders.clear();
}
LLMQore::RequestID Session::send(std::vector<std::unique_ptr<LLMQore::ContentBlock>> userBlocks)
{
if (!canSend()) {
@@ -165,19 +171,24 @@ LLMQore::RequestID Session::send(std::vector<std::unique_ptr<LLMQore::ContentBlo
return {};
}
if (userBlocks.empty() || !m_history) {
m_lastError = makeError(ErrorCategory::Validation, QStringLiteral("Session: nothing to send"));
m_lastError
= makeError(ErrorCategory::Validation, QStringLiteral("Session: nothing to send"));
return {};
}
if (isInFlight())
cancel();
const int preSendSize = m_history->size();
Message msg(Message::Role::User);
for (auto &b : userBlocks)
msg.appendBlock(std::move(b));
m_history->append(std::move(msg));
return dispatch();
const auto id = dispatch();
if (id.isEmpty())
m_history->resetTo(preSendSize);
return id;
}
QVector<ContextAssembler::PinnedBlock> Session::materializePinned() const
@@ -223,8 +234,8 @@ LLMQore::RequestID Session::dispatch()
m_systemPrompt->clearLayer(QStringLiteral("agent.system"));
} else {
QString renderErr;
const QString renderedContext = Templates::ContextRenderer::render(
cfg.systemPrompt, m_contextBindings, &renderErr);
const QString renderedContext
= Templates::ContextRenderer::render(cfg.systemPrompt, m_contextBindings, &renderErr);
if (!renderErr.isEmpty()) {
m_lastError = makeError(
ErrorCategory::Validation,
@@ -237,7 +248,9 @@ LLMQore::RequestID Session::dispatch()
m_systemPrompt->clearLayer(QStringLiteral("agent.system"));
else
m_systemPrompt->setLayer(
QStringLiteral("agent.system"), renderedContext, SystemPromptBuilder::kAgentPriority);
QStringLiteral("agent.system"),
renderedContext,
SystemPromptBuilder::kAgentPriority);
}
return dispatchContext(assembleContext(), cfg.enableTools);
@@ -259,13 +272,37 @@ LLMQore::RequestID Session::dispatchContext(const Templates::ContextData &ctx, b
QString endpoint = cfg.endpoint;
endpoint.replace(QStringLiteral("${MODEL}"), cfg.model);
LLMQore::RequestID earlyFailId;
QString earlyFailError;
QMetaObject::Connection earlyFailConn;
if (auto *cl = client()) {
earlyFailConn = connect(
cl,
&LLMQore::BaseClient::requestFailed,
this,
[&earlyFailId, &earlyFailError](const LLMQore::RequestID &fid, const QString &err) {
earlyFailId = fid;
earlyFailError = err;
},
Qt::DirectConnection);
}
const auto id = provider->sendRequest(QUrl(provider->url()), payload, endpoint);
if (earlyFailConn)
disconnect(earlyFailConn);
if (id.isEmpty()) {
m_lastError = makeError(
ErrorCategory::Provider,
QStringLiteral("Provider '%1' failed to start the request").arg(provider->name()));
return {};
}
if (id == earlyFailId && !earlyFailError.isEmpty()) {
m_lastError = makeError(categorizeProviderError(earlyFailError), earlyFailError);
return {};
}
m_inFlight = id;
if (m_router)
@@ -274,8 +311,7 @@ LLMQore::RequestID Session::dispatchContext(const Templates::ContextData &ctx, b
return id;
}
QJsonObject Session::buildPayload(
const Templates::ContextData &ctx, bool tools, QString *errOut) const
QJsonObject Session::buildPayload(const Templates::ContextData &ctx, bool tools, QString *errOut) const
{
auto *provider = m_agent->provider();
auto *tmpl = m_agent->promptTemplate();
@@ -302,7 +338,7 @@ Templates::ContextData Session::assembleContext() const
void Session::onRouterEvent(const ResponseEvent &ev)
{
if (m_inFlight.isEmpty())
return; // stale events after cancel
return;
emit event(ev);

View File

@@ -56,10 +56,12 @@ public:
using PinnedProvider = std::function<QString()>;
void pinContext(const QString &id, PinnedProvider provider);
void unpinContext(const QString &id);
void clearPinnedContext();
Agent *agent() noexcept { return m_agent; }
void setAgent(Agent *agent);
ConversationHistory *history() const noexcept { return m_history; }
bool usesExternalHistory() const noexcept { return m_externalHistory; }
SystemPromptBuilder *systemPrompt() const noexcept { return m_systemPrompt; }
LLMQore::BaseClient *client() const noexcept;
@@ -89,10 +91,11 @@ private:
QVector<ContextAssembler::PinnedBlock> materializePinned() const;
QJsonObject buildPayload(const Templates::ContextData &ctx, bool tools, QString *errOut) const;
Agent *m_agent = nullptr; // child if non-null
QPointer<ConversationHistory> m_history; // child if internal, external otherwise
SystemPromptBuilder *m_systemPrompt = nullptr; // child
ResponseRouter *m_router = nullptr; // child, only when valid
Agent *m_agent = nullptr;
QPointer<ConversationHistory> m_history;
SystemPromptBuilder *m_systemPrompt = nullptr;
ResponseRouter *m_router = nullptr;
bool m_externalHistory = false;
LLMQore::RequestID m_inFlight;
QString m_invalidReason;

View File

@@ -27,7 +27,7 @@ SessionManager::~SessionManager() = default;
Session *SessionManager::createSession(const QString &agentName, QString *errorOut)
{
return createSession(agentName, /*externalHistory=*/nullptr, errorOut);
return createSession(agentName, nullptr, errorOut);
}
Session *SessionManager::createSession(
@@ -40,7 +40,7 @@ Session *SessionManager::createSession(
}
QString agentErr;
Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr);
Agent *agent = m_agentFactory->create(agentName, nullptr, &agentErr);
if (!agent) {
if (errorOut)
*errorOut = agentErr.isEmpty()
@@ -53,7 +53,7 @@ Session *SessionManager::createSession(
if (!session->isValid()) {
if (errorOut)
*errorOut = session->invalidReason();
delete session; // also deletes the reparented agent
delete session;
return nullptr;
}
@@ -64,7 +64,7 @@ Session *SessionManager::createSession(
Session *SessionManager::createDetachedSession(ConversationHistory *externalHistory, QObject *parent)
{
return new Session(/*agent=*/nullptr, externalHistory, parent);
return new Session(nullptr, externalHistory, parent);
}
bool SessionManager::rebindAgentByName(Session *session, const QString &agentName, QString *errorOut)
@@ -81,7 +81,7 @@ bool SessionManager::rebindAgentByName(Session *session, const QString &agentNam
}
QString agentErr;
Agent *agent = m_agentFactory->create(agentName, /*parent=*/nullptr, &agentErr);
Agent *agent = m_agentFactory->create(agentName, nullptr, &agentErr);
if (!agent) {
if (errorOut)
*errorOut = agentErr.isEmpty()
@@ -113,7 +113,7 @@ Session *SessionManager::acquire(const QString &agentName, QString *errorOut)
pooled->deleteLater();
}
return createSession(agentName, /*externalHistory=*/nullptr, errorOut);
return createSession(agentName, nullptr, errorOut);
}
void SessionManager::release(Session *session)
@@ -130,10 +130,16 @@ void SessionManager::release(Session *session)
session->cancel();
session->disconnect();
if (session->usesExternalHistory()) {
emit sessionRemoved(session);
session->deleteLater();
return;
}
resetSession(session);
const QString agentName
= session->agent() ? session->agent()->config().name : QString();
const QString agentName = session->agent() ? session->agent()->config().name : QString();
QList<QPointer<Session>> &bucket = m_pool[agentName];
if (agentName.isEmpty() || bucket.size() >= kMaxPooledPerAgent) {
emit sessionRemoved(session);
@@ -155,6 +161,9 @@ void SessionManager::resetSession(Session *session)
if (auto *tools = client->tools())
tools->removeAllTools();
}
session->setContentLoader({});
session->setContextBindings({});
session->clearPinnedContext();
}
bool SessionManager::pooledAgentMatchesCurrent(Session *session, const QString &agentName) const

View File

@@ -53,8 +53,7 @@ signals:
private:
void resetSession(Session *session);
void flushPool();
[[nodiscard]] bool pooledAgentMatchesCurrent(
Session *session, const QString &agentName) const;
[[nodiscard]] bool pooledAgentMatchesCurrent(Session *session, const QString &agentName) const;
static constexpr int kMaxPooledPerAgent = 2;

View File

@@ -49,7 +49,8 @@ void SystemPromptBuilder::clear()
QString SystemPromptBuilder::layer(const QString &name) const
{
for (const auto &l : m_layers) {
if (l.name == name) return l.text;
if (l.name == name)
return l.text;
}
return {};
}
@@ -58,16 +59,17 @@ QStringList SystemPromptBuilder::layerNames() const
{
QStringList out;
out.reserve(m_layers.size());
for (const auto &l : m_layers) out.append(l.name);
for (const auto &l : m_layers)
out.append(l.name);
return out;
}
QString SystemPromptBuilder::compose(const QString &separator) const
{
QVector<Layer> ordered = m_layers;
std::stable_sort(
ordered.begin(), ordered.end(),
[](const Layer &a, const Layer &b) { return a.priority < b.priority; });
std::stable_sort(ordered.begin(), ordered.end(), [](const Layer &a, const Layer &b) {
return a.priority < b.priority;
});
QStringList parts;
parts.reserve(ordered.size());

View File

@@ -56,7 +56,8 @@ Agent::Agent(AgentConfig config, Providers::Provider *providerOwned, QObject *pa
}
m_provider->setParent(this);
m_provider->setPromptCaching(
m_config.cachePrompt, m_config.cacheTtl == QLatin1StringView{"1h"},
m_config.cachePrompt,
m_config.cacheTtl == QLatin1StringView{"1h"},
m_config.cacheBreakpoints);
QString tmplErr;

View File

@@ -46,8 +46,8 @@ public:
private:
AgentConfig m_config;
std::unique_ptr<Templates::JsonPromptTemplate> m_promptTemplate; // owned
Providers::Provider *m_provider = nullptr; // child of this
std::unique_ptr<Templates::JsonPromptTemplate> m_promptTemplate;
Providers::Provider *m_provider = nullptr;
QString m_invalidReason;
};

View File

@@ -124,8 +124,8 @@ AgentConfig configFromMerged(const QJsonObject &obj)
cfg.systemPrompt = obj.value("system_prompt").toString();
cfg.enableThinking = obj.value("enable_thinking").toBool(false);
cfg.enableTools = obj.value("enable_tools").toBool(false);
cfg.cachePrompt = obj.value("cache_prompt").toBool(false);
cfg.cacheTtl = obj.value("cache_ttl").toString();
cfg.cachePrompt = obj.value("cache_prompt").toBool(false);
cfg.cacheTtl = obj.value("cache_ttl").toString();
cfg.cacheBreakpoints = stringArray(obj.value("cache_breakpoints"));
cfg.tags = stringArray(obj.value("tags"));
@@ -153,26 +153,34 @@ constexpr int kMaxExtendsDepth = 32;
void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringList &warnings)
{
static const QSet<QString> kTopLevelKeys = {
QStringLiteral("schema_version"), QStringLiteral("name"),
QStringLiteral("description"), QStringLiteral("provider_instance"),
QStringLiteral("model"), QStringLiteral("endpoint"),
QStringLiteral("system_prompt"), QStringLiteral("tags"),
QStringLiteral("match"), QStringLiteral("enable_thinking"),
QStringLiteral("enable_tools"), QStringLiteral("cache_prompt"),
QStringLiteral("cache_ttl"), QStringLiteral("cache_breakpoints"),
QStringLiteral("body"),
QStringLiteral("extends"), QStringLiteral("abstract"),
QStringLiteral("hidden")};
static const QSet<QString> kMatchKeys = {
QStringLiteral("file_patterns"),
QStringLiteral("path_patterns"),
QStringLiteral("project_names")};
static const QSet<QString> kTopLevelKeys
= {QStringLiteral("schema_version"),
QStringLiteral("name"),
QStringLiteral("description"),
QStringLiteral("provider_instance"),
QStringLiteral("model"),
QStringLiteral("endpoint"),
QStringLiteral("system_prompt"),
QStringLiteral("tags"),
QStringLiteral("match"),
QStringLiteral("enable_thinking"),
QStringLiteral("enable_tools"),
QStringLiteral("cache_prompt"),
QStringLiteral("cache_ttl"),
QStringLiteral("cache_breakpoints"),
QStringLiteral("body"),
QStringLiteral("extends"),
QStringLiteral("abstract"),
QStringLiteral("hidden")};
static const QSet<QString> kMatchKeys
= {QStringLiteral("file_patterns"),
QStringLiteral("path_patterns"),
QStringLiteral("project_names")};
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) {
if (!kTopLevelKeys.contains(it.key())) {
warnings.append(QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)")
.arg(it.key(), filePath));
warnings.append(
QStringLiteral("Unknown key '%1' in %2 — ignored (typo?)").arg(it.key(), filePath));
}
}
const QJsonObject matchObj = obj.value("match").toObject();
@@ -183,12 +191,13 @@ void lintUnknownKeys(const QJsonObject &obj, const QString &filePath, QStringLis
}
}
static const QSet<QString> kCacheBreakpoints = {
QStringLiteral("system"), QStringLiteral("tools"), QStringLiteral("history")};
static const QSet<QString> kCacheBreakpoints
= {QStringLiteral("system"), QStringLiteral("tools"), QStringLiteral("history")};
for (const QJsonValue &bp : obj.value("cache_breakpoints").toArray()) {
if (bp.isString() && !kCacheBreakpoints.contains(bp.toString())) {
warnings.append(QStringLiteral("Unknown cache_breakpoint '%1' in %2 — ignored "
"(use system/tools/history)")
warnings.append(QStringLiteral(
"Unknown cache_breakpoint '%1' in %2 — ignored "
"(use system/tools/history)")
.arg(bp.toString(), filePath));
}
}
@@ -201,10 +210,12 @@ void scanDir(
QStringList &errors,
QStringList *warnings)
{
if (dir.isEmpty()) return;
if (dir.isEmpty())
return;
QDir d(dir);
if (!d.exists()) return;
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
if (!d.exists())
return;
const QStringList files = d.entryList({"*.toml"}, QDir::Files, QDir::Name);
for (const QString &fname : files) {
const QString fullPath = d.filePath(fname);
QString err;
@@ -222,17 +233,16 @@ void scanDir(
lintUnknownKeys(*objOpt, fullPath, *warnings);
const auto existing = raw.constFind(name);
if (existing != raw.constEnd() && existing->isUserLayer != isUserLayer) {
errors.append(
QStringLiteral("Agent '%1' at %2 has the same name as a bundled agent — "
"bundled agents cannot be replaced; rename it and use "
"'extends' to build on the bundled one")
.arg(name, fullPath));
errors.append(QStringLiteral(
"Agent '%1' at %2 has the same name as a bundled agent — "
"bundled agents cannot be replaced; rename it and use "
"'extends' to build on the bundled one")
.arg(name, fullPath));
continue;
}
if (warnings && existing != raw.constEnd()) {
warnings->append(
QStringLiteral("Agent '%1' is defined in both %2 and %3 — %3 wins")
.arg(name, existing->filePath, fullPath));
warnings->append(QStringLiteral("Agent '%1' is defined in both %2 and %3 — %3 wins")
.arg(name, existing->filePath, fullPath));
}
raw.insert(name, {*objOpt, fullPath, isUserLayer});
}
@@ -251,7 +261,7 @@ QJsonObject mergeChild(const QJsonObject &parentMerged, const QJsonObject &self,
return merged;
}
QJsonObject resolveExtends(
std::optional<QJsonObject> resolveExtends(
const QString &name,
const QHash<QString, RawEntry> &raw,
QSet<QString> &visiting,
@@ -262,15 +272,15 @@ QJsonObject resolveExtends(
errors.append(QStringLiteral("Agent extends chain too deep (>%1) at '%2'")
.arg(kMaxExtendsDepth)
.arg(name));
return {};
return std::nullopt;
}
if (visiting.contains(name)) {
errors.append(QStringLiteral("Cyclic 'extends' involving agent '%1'").arg(name));
return {};
return std::nullopt;
}
if (!raw.contains(name)) {
errors.append(QStringLiteral("Unknown agent '%1'").arg(name));
return {};
return std::nullopt;
}
visiting.insert(name);
@@ -281,11 +291,14 @@ QJsonObject resolveExtends(
errors.append(QStringLiteral("Agent '%1' extends unknown agent '%2' (%3)")
.arg(name, parent, raw.value(name).filePath));
visiting.remove(name);
return {};
return std::nullopt;
}
const QJsonObject parentMerged
= resolveExtends(parent, raw, visiting, errors, depth + 1);
self = mergeChild(parentMerged, self, name);
const auto parentMerged = resolveExtends(parent, raw, visiting, errors, depth + 1);
if (!parentMerged) {
visiting.remove(name);
return std::nullopt;
}
self = mergeChild(*parentMerged, self, name);
}
visiting.remove(name);
return self;
@@ -294,17 +307,15 @@ QJsonObject resolveExtends(
} // namespace
std::optional<AgentConfig> AgentLoader::parseFile(
const QString &path,
const QString &qrcPrefix,
QString *error,
QStringList *warnings)
const QString &path, const QString &qrcPrefix, QString *error, QStringList *warnings)
{
auto objOpt = parseTomlFile(path, error);
if (!objOpt) return std::nullopt;
const QString name = objOpt->value("name").toString();
if (name.isEmpty()) {
if (error) *error = QStringLiteral("Agent at %1 has no 'name'").arg(path);
if (error)
*error = QStringLiteral("Agent at %1 has no 'name'").arg(path);
return std::nullopt;
}
if (warnings)
@@ -312,28 +323,40 @@ std::optional<AgentConfig> AgentLoader::parseFile(
QHash<QString, RawEntry> raw;
QStringList scanErrors;
scanDir(qrcPrefix, /*isUserLayer=*/false, raw, scanErrors, nullptr);
scanDir(QFileInfo(path).absolutePath(), /*isUserLayer=*/true, raw, scanErrors, nullptr);
raw.insert(name, {*objOpt, path, true});
scanDir(qrcPrefix, false, raw, scanErrors, nullptr);
QSet<QString> visiting;
QStringList resolveErrors;
const QJsonObject merged = resolveExtends(name, raw, visiting, resolveErrors);
if (!resolveErrors.isEmpty() || merged.isEmpty()) {
const auto bundled = raw.constFind(name);
if (bundled != raw.constEnd() && !bundled->isUserLayer) {
if (error) {
*error = resolveErrors.isEmpty()
? QStringLiteral("Agent '%1' resolved to an empty config").arg(name)
: resolveErrors.join(QStringLiteral("; "));
*error = QStringLiteral(
"Agent '%1' at %2 has the same name as a bundled agent — bundled "
"agents cannot be replaced; rename it and use 'extends' to build "
"on the bundled one")
.arg(name, path);
}
return std::nullopt;
}
AgentConfig cfg = configFromMerged(merged);
scanDir(QFileInfo(path).absolutePath(), true, raw, scanErrors, nullptr);
raw.insert(name, {*objOpt, path, true});
QSet<QString> visiting;
QStringList resolveErrors;
const auto merged = resolveExtends(name, raw, visiting, resolveErrors);
if (!merged) {
if (error)
*error = resolveErrors.join(QStringLiteral("; "));
return std::nullopt;
}
AgentConfig cfg = configFromMerged(*merged);
cfg.sourcePath = path;
if (cfg.abstract) {
if (error) {
*error = QStringLiteral("Agent '%1' is abstract — extend it instead of "
"loading it directly").arg(name);
*error = QStringLiteral(
"Agent '%1' is abstract — extend it instead of "
"loading it directly")
.arg(name);
}
return std::nullopt;
}
@@ -345,8 +368,8 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin
LoadResult result;
QHash<QString, RawEntry> raw;
scanDir(qrcPrefix, /*isUserLayer=*/false, raw, result.errors, &result.warnings);
scanDir(userDir, /*isUserLayer=*/true, raw, result.errors, &result.warnings);
scanDir(qrcPrefix, false, raw, result.errors, &result.warnings);
scanDir(userDir, true, raw, result.errors, &result.warnings);
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it)
result.sourcePathByName.insert(it.key(), it.value().filePath);
@@ -355,10 +378,11 @@ AgentLoader::LoadResult AgentLoader::load(const QString &qrcPrefix, const QStrin
const QString &name = it.key();
QSet<QString> visiting;
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
if (merged.isEmpty()) continue;
const auto merged = resolveExtends(name, raw, visiting, result.errors);
if (!merged)
continue;
AgentConfig cfg = configFromMerged(merged);
AgentConfig cfg = configFromMerged(*merged);
cfg.sourcePath = it.value().filePath;
if (cfg.abstract) continue;

View File

@@ -4,10 +4,10 @@
#pragma once
#include <vector>
#include <QHash>
#include <QString>
#include <QStringList>
#include <vector>
#include "AgentConfig.hpp"

View File

@@ -24,11 +24,11 @@ QRegularExpression compiledGlob(const QString &pattern)
const auto it = cache.constFind(pattern);
if (it != cache.constEnd())
return *it;
const QRegularExpression re(
QRegularExpression::anchoredPattern(
QRegularExpression::wildcardToRegularExpression(
pattern, QRegularExpression::NonPathWildcardConversion)),
QRegularExpression::CaseInsensitiveOption);
const QRegularExpression
re(QRegularExpression::anchoredPattern(
QRegularExpression::wildcardToRegularExpression(
pattern, QRegularExpression::NonPathWildcardConversion)),
QRegularExpression::CaseInsensitiveOption);
cache.insert(pattern, re);
return re;
}
@@ -67,11 +67,9 @@ bool matchesPathPatterns(const QStringList &patterns, const QString &filePath)
bool matchesProjectNames(const QStringList &names, const QString &projectName)
{
if (names.isEmpty())
return true; // dimension unconstrained
return true;
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);
}
@@ -80,7 +78,7 @@ bool matchesProjectNames(const QStringList &names, const QString &projectName)
bool matches(const AgentConfig::Match &m, const Context &ctx)
{
if (m.isEmpty())
return true; // explicit catch-all
return true;
return matchesFilePatterns(m.filePatterns, ctx.filePath)
&& matchesPathPatterns(m.pathPatterns, ctx.filePath)
&& matchesProjectNames(m.projectNames, ctx.projectName);
@@ -92,7 +90,7 @@ QString pickAgent(
for (const QString &name : roster) {
const AgentConfig *cfg = factory.configByName(name);
if (!cfg)
continue; // stale roster entry — silently skip
continue;
if (matches(cfg->match, ctx))
return name;
}

View File

@@ -61,11 +61,18 @@ QString expandAndResolvePath(const QString &raw, const Bindings &b)
return p;
}
bool hasUnresolvedVars(const QString &p)
{
return p.contains(QStringLiteral("${PROJECT_DIR}"))
|| p.contains(QStringLiteral("${CONFIG_DIR}"));
}
[[noreturn]] void throwOutsideRoots(const char *fn, const QString &path)
{
throw std::runtime_error(
QStringLiteral("%1: path is outside the allowed read roots "
"(the project directory, ~/qodeassist, or bundled :/ resources): %2")
QStringLiteral(
"%1: path is outside the allowed read roots "
"(the project directory, ~/qodeassist, or bundled :/ resources): %2")
.arg(QString::fromLatin1(fn), path)
.toStdString());
}
@@ -77,6 +84,13 @@ void registerReadFile(inja::Environment &env, const Bindings &b)
const QString path
= expandAndResolvePath(QString::fromStdString(args.at(0)->get<std::string>()), caps);
if (hasUnresolvedVars(path)) {
throw std::runtime_error(QStringLiteral(
"read_file: ${PROJECT_DIR}/${CONFIG_DIR} is not available "
"(no project open?): %1")
.arg(path)
.toStdString());
}
if (!isPathAllowed(path, caps))
throwOutsideRoots("read_file", path);
@@ -97,6 +111,8 @@ void registerFileExists(inja::Environment &env, const Bindings &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 (hasUnresolvedVars(p))
return false;
if (!isPathAllowed(p, caps))
throwOutsideRoots("file_exists", p);
return QFileInfo::exists(p);
@@ -110,6 +126,8 @@ void registerReadDir(inja::Environment &env, const Bindings &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 (hasUnresolvedVars(p))
return nlohmann::json::array();
if (!isPathAllowed(p, caps))
throwOutsideRoots("read_dir", p);
QDir dir(p);

View File

@@ -16,6 +16,8 @@
<file>openai_completion.toml</file>
<file>openai_compression.toml</file>
<file>openai_quick_refactor.toml</file>
<file>deepseek_chat.toml</file>
<file>qwen_chat.toml</file>
<file>google_base_chat.toml</file>
<file>google_chat.toml</file>
<file>google_completion.toml</file>

View File

@@ -0,0 +1,16 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "DeepSeek Chat"
description = "DeepSeek coding chat (deepseek-chat, V4 family) over the OpenAI-compatible Chat Completions API — conversational assistant with IDE tool use."
provider_instance = "DeepSeek"
model = "deepseek-chat"
enable_tools = true
tags = ["chat", "deepseek", "cloud"]
system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
[body]
max_tokens = 8192
temperature = 0.3

View File

@@ -12,11 +12,11 @@ tags = ["chat", "ollama", "local", "16gb"]
system_prompt = """{{ read_file(":/roles/agentic-coder.md") }}"""
[body]
think = true
think = true
keep_alive = "5m"
[body.options]
temperature = 1.0
top_p = 0.95
top_k = 64
num_predict = 8096
keep_alive = "5m"

View File

@@ -2,14 +2,16 @@ schema_version = 1
extends = "Ollama Base Chat"
name = "Ollama Chat — Simple"
description = "Local Ollama coding chat for any model — plain conversational assistant, no tools, no thinking (Qwen2.5-Coder 7B by default)."
description = "Local Ollama coding chat for any model — plain conversational assistant, no tools, no thinking (Qwen3.5 4B by default)."
model = "qwen3.5:4b"
tags = ["chat", "ollama", "local", "8gb"]
system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
[body]
keep_alive = "5m"
[body.options]
num_predict = 2048
temperature = 0.7
keep_alive = "5m"

View File

@@ -12,8 +12,8 @@ tags = ["chat", "ollama", "local", "16gb"]
system_prompt = """{{ read_file(":/roles/agentic-coder.md") }}"""
[body]
think = true
think = true
keep_alive = "5m"
[body.options]
num_predict = 8192
keep_alive = "5m"

View File

@@ -15,6 +15,7 @@ system_prompt = """
{{ read_file(":/tasks/code-completion.md") }}"""
[body]
keep_alive = "5m"
messages = """
[
{% if existsIn(ctx, "system_prompt") %}
@@ -27,5 +28,4 @@ messages = """
[body.options]
num_predict = 256
temperature = 0.2
keep_alive = "5m"
stop = ["</code_context>"]

View File

@@ -7,7 +7,9 @@ description = "Native fill-in-the-middle completion — uses the model's OWN FIM
model = "qwen2.5-coder:7b-base-q5_K_M"
tags = ["completion", "ollama", "local", "fim", "8gb"]
[body]
keep_alive = "5m"
[body.options]
num_predict = 256
temperature = 0
keep_alive = "5m"

View File

@@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "16gb"]
system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}"""
[body]
think = false
think = false
keep_alive = "5m"
[body.options]
num_predict = 2048
temperature = 0.3
num_ctx = 8192
keep_alive = "5m"

View File

@@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "32gb"]
system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}"""
[body]
think = false
think = false
keep_alive = "5m"
[body.options]
num_predict = 2048
temperature = 0.3
num_ctx = 24576
keep_alive = "5m"

View File

@@ -11,10 +11,10 @@ tags = ["compression", "ollama", "local", "8gb"]
system_prompt = """{{ read_file(":/tasks/chat-compressor.md") }}"""
[body]
think = false
think = false
keep_alive = "5m"
[body.options]
num_predict = 2048
temperature = 0.3
num_ctx = 8192
keep_alive = "5m"

View File

@@ -12,8 +12,8 @@ tags = ["refactor", "ollama", "local", "16gb"]
system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}"""
[body]
think = true
think = true
keep_alive = "5m"
[body.options]
num_predict = 4096
keep_alive = "5m"

View File

@@ -12,8 +12,8 @@ tags = ["refactor", "ollama", "local", "16gb"]
system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}"""
[body]
think = true
think = true
keep_alive = "5m"
[body.options]
num_predict = 4096
keep_alive = "5m"

View File

@@ -10,7 +10,9 @@ tags = ["refactor", "ollama", "local", "8gb"]
system_prompt = """{{ read_file(":/tasks/quick-refactor.md") }}"""
[body]
keep_alive = "5m"
[body.options]
num_predict = 2048
temperature = 0.2
keep_alive = "5m"

View File

@@ -0,0 +1,16 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "Qwen Chat"
description = "Alibaba Qwen coding chat (qwen-plus stable alias, currently the Qwen3.6 Plus family) over the DashScope OpenAI-compatible Chat Completions API — conversational assistant with IDE tool use."
provider_instance = "Qwen"
model = "qwen-plus"
enable_tools = true
tags = ["chat", "qwen", "cloud"]
system_prompt = """{{ read_file(":/roles/qt-cpp-developer.md") }}"""
[body]
max_tokens = 8192
temperature = 0.3

View File

@@ -24,15 +24,15 @@ struct ContentBlockEntry
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
QString text;
QString thinking;
QString signature;
QString toolUseId;
QString toolName;
QJsonObject toolInput;
QString result;
QString imageData;
QString mediaType;
bool isImageUrl = false;
bool operator==(const ContentBlockEntry &) const = default;
@@ -43,7 +43,6 @@ 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;

View File

@@ -4,9 +4,9 @@
#pragma once
#include <QRegularExpression>
#include <QString>
#include <QStringList>
#include <QRegularExpression>
namespace QodeAssist {
@@ -15,14 +15,15 @@ class ResponseCleaner
public:
static QString clean(const QString &response)
{
QString cleaned = removeCodeBlocks(response);
bool extractedFromFence = false;
QString cleaned = removeCodeBlocks(response, extractedFromFence);
cleaned = trimWhitespace(cleaned);
cleaned = removeExplanations(cleaned);
cleaned = removeExplanations(cleaned, extractedFromFence);
return cleaned;
}
private:
static QString removeCodeBlocks(const QString &text)
static QString removeCodeBlocks(const QString &text, bool &extractedFromFence)
{
if (!text.contains("```")) {
return text;
@@ -31,6 +32,7 @@ private:
QRegularExpression codeBlockRegex("```\\w*\\n([\\s\\S]*?)```");
QRegularExpressionMatch match = codeBlockRegex.match(text);
if (match.hasMatch()) {
extractedFromFence = true;
return match.captured(1);
}
@@ -39,6 +41,7 @@ private:
if (firstFence != -1 && lastFence > firstFence) {
int firstNewLine = text.indexOf('\n', firstFence);
if (firstNewLine != -1) {
extractedFromFence = true;
return text.mid(firstNewLine + 1, lastFence - firstNewLine - 1);
}
}
@@ -58,13 +61,34 @@ private:
return result;
}
static QString removeExplanations(const QString &text)
static bool isProseHeader(const QString &line)
{
static const QStringList explanationPrefixes = {
"here's the", "here is the", "here's", "here is",
"the refactored", "refactored code:", "code:",
"i've refactored", "i refactored", "i've changed", "i changed"
};
if (line.length() >= 50 || !line.endsWith(':') || !line.contains(' ')) {
return false;
}
const QString head = line.left(line.length() - 1);
for (const QChar c : head) {
if (!c.isLetter() && c != ' ') {
return false;
}
}
return true;
}
static QString removeExplanations(const QString &text, bool extractedFromFence)
{
static const QStringList explanationPrefixes
= {"here's the",
"here is the",
"here's",
"here is",
"the refactored",
"refactored code:",
"code:",
"i've refactored",
"i refactored",
"i've changed",
"i changed"};
QStringList lines = text.split('\n');
int startLine = 0;
@@ -80,7 +104,7 @@ private:
}
}
if (line.length() < 50 && line.endsWith(':')) {
if (!extractedFromFence && isProseHeader(line)) {
isExplanation = true;
}

View File

@@ -38,8 +38,8 @@ inline void applyToSystem(QJsonObject &request, const QJsonObject &cacheControl)
if (sys.isString()) {
const QString text = sys.toString();
if (!text.isEmpty()) {
request["system"] = QJsonArray{QJsonObject{
{"type", "text"}, {"text", text}, {"cache_control", cacheControl}}};
request["system"] = QJsonArray{
QJsonObject{{"type", "text"}, {"text", text}, {"cache_control", cacheControl}}};
}
} else if (sys.isArray()) {
QJsonArray blocks = sys.toArray();

View File

@@ -73,8 +73,6 @@ QString GenericProvider::modelsEndpoint(const QString &url) const
RequestID GenericProvider::sendRequest(
const QUrl &url, const QJsonObject &payload, const QString &endpoint)
{
// Gemini carries the model in the URL and rejects unknown body fields, so
// the model/stream keys injected by the generic pipeline must be dropped.
if (m_id == ProviderID::GoogleAI) {
QJsonObject cleaned = payload;
cleaned.remove("model");
@@ -98,9 +96,7 @@ GenericProvider::ClientFactory makeFactory()
void registerBuiltinProviders()
{
const auto reg = [](const QString &api,
ProviderID id,
GenericProvider::ClientFactory factory) {
const auto reg = [](const QString &api, ProviderID id, GenericProvider::ClientFactory factory) {
ProviderFactory::registerType(api, [=](QObject *parent) -> Provider * {
return new GenericProvider(api, id, factory, parent);
});
@@ -109,21 +105,23 @@ void registerBuiltinProviders()
reg("Claude", ProviderID::Claude, makeFactory<::LLMQore::ClaudeClient>());
reg("Google AI", ProviderID::GoogleAI, makeFactory<::LLMQore::GoogleAIClient>());
reg("llama.cpp", ProviderID::LlamaCpp, makeFactory<::LLMQore::LlamaCppClient>());
reg("LM Studio (Chat Completions)", ProviderID::LMStudio,
reg("LM Studio (Chat Completions)",
ProviderID::LMStudio,
makeFactory<::LLMQore::OpenAIClient>());
reg("LM Studio (Responses API)", ProviderID::OpenAIResponses,
reg("LM Studio (Responses API)",
ProviderID::OpenAIResponses,
makeFactory<::LLMQore::OpenAIResponsesClient>());
reg("Mistral AI", ProviderID::MistralAI, makeFactory<::LLMQore::MistralClient>());
reg("Codestral", ProviderID::MistralAI, makeFactory<::LLMQore::MistralClient>());
reg("Ollama (Native)", ProviderID::Ollama, makeFactory<::LLMQore::OllamaClient>());
reg("Ollama (OpenAI-compatible)", ProviderID::OpenAICompatible,
reg("Ollama (OpenAI-compatible)",
ProviderID::OpenAICompatible,
makeFactory<::LLMQore::OpenAIClient>());
reg("OpenAI (Chat Completions)", ProviderID::OpenAI,
makeFactory<::LLMQore::OpenAIClient>());
reg("OpenAI (Responses API)", ProviderID::OpenAIResponses,
reg("OpenAI (Chat Completions)", ProviderID::OpenAI, makeFactory<::LLMQore::OpenAIClient>());
reg("OpenAI (Responses API)",
ProviderID::OpenAIResponses,
makeFactory<::LLMQore::OpenAIResponsesClient>());
reg("OpenAI Compatible", ProviderID::OpenAICompatible,
makeFactory<::LLMQore::OpenAIClient>());
reg("OpenAI Compatible", ProviderID::OpenAICompatible, makeFactory<::LLMQore::OpenAIClient>());
reg("OpenRouter", ProviderID::OpenRouter, makeFactory<::LLMQore::OpenAIClient>());
}

View File

@@ -14,10 +14,6 @@ class BaseClient;
namespace QodeAssist::Providers {
// A configuration-driven provider: it owns an LLMQore client and exposes a
// fixed identity. Concrete behaviour (request shape) comes from the agent's
// prompt template via Provider::prepareRequest, so a single class covers
// every client_api by varying the client factory + metadata.
class GenericProvider : public Provider
{
Q_OBJECT
@@ -25,10 +21,7 @@ public:
using ClientFactory = std::function<::LLMQore::BaseClient *(QObject *)>;
GenericProvider(
QString name,
ProviderID id,
const ClientFactory &clientFactory,
QObject *parent = nullptr);
QString name, ProviderID id, const ClientFactory &clientFactory, QObject *parent = nullptr);
QString name() const override;
QFuture<QList<QString>> getInstalledModels(const QString &url) override;
@@ -46,8 +39,6 @@ private:
::LLMQore::BaseClient *m_client;
};
// Registers every built-in client_api into ProviderFactory. Must be called once
// at plugin startup before any agent/session is created.
void registerBuiltinProviders();
} // namespace QodeAssist::Providers

View File

@@ -40,14 +40,13 @@ bool Provider::prepareRequest(
return fail(QString("Provider '%1': null template").arg(name()));
if (!prompt->isSupportProvider(providerID())) {
return fail(QString("Template '%1' doesn't support provider '%2'")
.arg(prompt->name(), name()));
return fail(
QString("Template '%1' doesn't support provider '%2'").arg(prompt->name(), name()));
}
if (!prompt->buildFullRequest(request, context)) {
return fail(
QString("Provider '%1': template '%2' failed to build request (see log)")
.arg(name(), prompt->name()));
return fail(QString("Provider '%1': template '%2' failed to build request (see log)")
.arg(name(), prompt->name()));
}
if (isToolsEnabled) {
@@ -58,18 +57,23 @@ bool Provider::prepareRequest(
}
if (m_promptCachingEnabled)
ClaudeCacheControl::apply(
request, m_promptCachingExtendedTtl, m_promptCacheBreakpoints);
ClaudeCacheControl::apply(request, m_promptCachingExtendedTtl, m_promptCacheBreakpoints);
return true;
}
void Provider::setPromptCaching(bool enabled, bool extendedTtl, const QStringList &breakpoints)
{
auto *claude = qobject_cast<::LLMQore::ClaudeClient *>(client());
if (enabled && !claude) {
LOG_MESSAGE(
QString("%1: cache_prompt is only supported by Claude providers, ignoring").arg(name()));
enabled = false;
}
m_promptCachingEnabled = enabled;
m_promptCachingExtendedTtl = enabled && extendedTtl;
m_promptCacheBreakpoints = breakpoints;
if (auto *claude = qobject_cast<::LLMQore::ClaudeClient *>(client()))
if (claude)
claude->setUseExtendedCacheTTL(m_promptCachingExtendedTtl);
}

View File

@@ -8,14 +8,12 @@
#include <QObject>
#include <QString>
#include <QStringList>
#include <utils/environment.h>
#include "ContextData.hpp"
#include "ProviderID.hpp"
#include "LLMQore/BaseClient.hpp"
namespace LLMQore {
class BaseClient;
class ToolsManager;
}
@@ -63,8 +61,7 @@ public:
void cancelRequest(const RequestID &requestId);
::LLMQore::ToolsManager *toolsManager() const;
void setPromptCaching(
bool enabled, bool extendedTtl, const QStringList &breakpoints = {});
void setPromptCaching(bool enabled, bool extendedTtl, const QStringList &breakpoints = {});
private:
QString m_url;

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "DeepSeek"
client_api = "OpenAI Compatible"
description = "Cloud (DeepSeek). OpenAI-compatible Chat Completions API — deepseek-chat (V4 family) and deepseek-reasoner. Get an API key at platform.deepseek.com."
url = "https://api.deepseek.com/v1"
api_key_ref = "qodeassist/providers/DeepSeek"

View File

@@ -13,5 +13,7 @@
<file>codestral.toml</file>
<file>googleai.toml</file>
<file>llamacpp.toml</file>
<file>deepseek.toml</file>
<file>qwen.toml</file>
</qresource>
</RCC>

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "Qwen"
client_api = "OpenAI Compatible"
description = "Cloud (Alibaba Qwen via DashScope, international endpoint). OpenAI-compatible Chat Completions API — qwen-plus / qwen-max / qwen-coder model families. Get an API key in the Alibaba Cloud Model Studio console."
url = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
api_key_ref = "qodeassist/providers/Qwen"

View File

@@ -33,12 +33,12 @@ namespace {
enum class PillKind {
Template,
On, // capability on (thinking/tools)
Off, // capability off ("plain")
User, // user-defined agent
Active, // ✓ active
Match, // matched-this-row chip background
Tag, // free-form discoverability tag from AgentConfig::tags
On,
Off,
User,
Active,
Match,
Tag,
Neutral,
};
@@ -192,16 +192,17 @@ class AgentRosterRow : public QFrame
{
Q_OBJECT
public:
AgentRosterRow(int index,
const QString &name,
const AgentConfig *cfg,
const QString &model,
bool active,
bool first,
bool last,
bool orderable,
const Theme &theme,
QWidget *parent = nullptr);
AgentRosterRow(
int index,
const QString &name,
const AgentConfig *cfg,
const QString &model,
bool active,
bool first,
bool last,
bool orderable,
const Theme &theme,
QWidget *parent = nullptr);
signals:
void moveUpRequested(int index);
@@ -221,17 +222,19 @@ private:
int m_index;
};
AgentRosterRow::AgentRosterRow(int index,
const QString &name,
const AgentConfig *cfg,
const QString &model,
bool active,
bool first,
bool last,
bool orderable,
const Theme &theme,
QWidget *parent)
: QFrame(parent), m_index(index)
AgentRosterRow::AgentRosterRow(
int index,
const QString &name,
const AgentConfig *cfg,
const QString &model,
bool active,
bool first,
bool last,
bool orderable,
const Theme &theme,
QWidget *parent)
: QFrame(parent)
, m_index(index)
{
setAutoFillBackground(true);
QPalette pal = palette();
@@ -334,8 +337,8 @@ QWidget *AgentRosterRow::buildIdentityLine(const QString &displayName,
return w;
}
QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool showMatch,
const Theme &t)
QWidget *AgentRosterRow::buildMetaLine(
const AgentConfig *cfg, bool active, bool showMatch, const Theme &t)
{
auto *w = new QWidget(this);
auto *line = new QHBoxLayout(w);
@@ -352,14 +355,16 @@ QWidget *AgentRosterRow::buildMetaLine(const AgentConfig *cfg, bool active, bool
QString chipText = QStringLiteral(
"<span style='opacity:0.7'>%1</span> "
"<span style='color:%2'>%3:</span> %4")
.arg(sm.icon,
hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
sm.kind,
sm.value.toHtmlEscaped());
.arg(
sm.icon,
hex(active ? t.pill(PillKind::Match).fg : t.textFaint),
sm.kind,
sm.value.toHtmlEscaped());
chip->setTextFormat(Qt::RichText);
chip->setText(chipText);
chip->setStyleSheet(QStringLiteral("background:%1; color:%2; border:1px solid %3;"
"padding:1px 6px; border-radius:3px; font-size:11px;")
chip->setStyleSheet(QStringLiteral(
"background:%1; color:%2; border:1px solid %3;"
"padding:1px 6px; border-radius:3px; font-size:11px;")
.arg(hex(bg), hex(fg), hex(bd)));
chip->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
line->addWidget(chip);
@@ -457,14 +462,14 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
m_rowsFrame = new QFrame(this);
m_rowsFrame->setObjectName(QStringLiteral("rosterCard"));
m_rowsFrame->setStyleSheet(
QStringLiteral("QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:4px; }")
QStringLiteral(
"QFrame#rosterCard { background:%1; border:1px solid %2; border-radius:4px; }")
.arg(hex(t.cardBg), hex(t.cardBorder)));
m_rowsLayout = new QVBoxLayout(m_rowsFrame);
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
m_rowsLayout->setSpacing(0);
m_emptyHint = new QLabel(tr("No agent selected yet — use \"Add agent…\" below."),
m_rowsFrame);
m_emptyHint = new QLabel(tr("No agent selected yet — use \"Add agent…\" below."), m_rowsFrame);
m_emptyHint->setAlignment(Qt::AlignCenter);
m_emptyHint->setContentsMargins(10, 12, 10, 12);
QFont eh = m_emptyHint->font();
@@ -497,9 +502,7 @@ AgentRosterWidget::AgentRosterWidget(QWidget *parent)
}
void AgentRosterWidget::setSlot(
const QString &title,
const QString &hint,
const QStringList &presetTags)
const QString &title, const QString &hint, const QStringList &presetTags)
{
m_presetTags = presetTags;
m_titleLabel->setText(title);
@@ -514,13 +517,14 @@ void AgentRosterWidget::setRoster(const QStringList &names, AgentFactory *factor
QObject::disconnect(m_modelConn);
m_factory = factory;
if (m_factory) {
m_factoryConn = connect(m_factory, &AgentFactory::agentsChanged, this,
[this] { rebuildRows(); });
m_modelConn = connect(m_factory, &AgentFactory::agentModelChanged, this,
[this](const QString &name) {
if (m_names.contains(name))
rebuildRows();
});
m_factoryConn = connect(m_factory, &AgentFactory::agentsChanged, this, [this] {
rebuildRows();
});
m_modelConn = connect(
m_factory, &AgentFactory::agentModelChanged, this, [this](const QString &name) {
if (m_names.contains(name))
rebuildRows();
});
}
}
m_names = names;
@@ -558,6 +562,13 @@ void AgentRosterWidget::applyMode()
m_footerHint->setVisible(!footer.isEmpty());
}
void AgentRosterWidget::setRoutingContext(const AgentRouter::Context &ctx)
{
m_routingCtx = ctx;
if (!m_names.isEmpty())
rebuildRows();
}
void AgentRosterWidget::recomputeActive()
{
if (!m_orderable || !m_factory || m_names.isEmpty()
@@ -571,7 +582,6 @@ void AgentRosterWidget::recomputeActive()
void AgentRosterWidget::rebuildRows()
{
// Tear down existing row widgets (keep the empty hint).
while (m_rowsLayout->count() > 0) {
QLayoutItem *it = m_rowsLayout->takeAt(0);
if (auto *w = it->widget()) {
@@ -598,16 +608,17 @@ void AgentRosterWidget::rebuildRows()
const QString &name = m_names.at(i);
const AgentConfig *cfg = m_factory ? m_factory->configByName(name) : nullptr;
const QString model = cfg ? cfg->model : QString();
auto *row = new AgentRosterRow(i,
name,
cfg,
model,
i == m_activeIndex,
/*first*/ i == 0,
/*last*/ i == m_names.size() - 1,
m_orderable,
t,
m_rowsFrame);
auto *row = new AgentRosterRow(
i,
name,
cfg,
model,
i == m_activeIndex,
/*first*/ i == 0,
/*last*/ i == m_names.size() - 1,
m_orderable,
t,
m_rowsFrame);
connect(row, &AgentRosterRow::moveUpRequested, this, &AgentRosterWidget::onRowMoveUp);
connect(row, &AgentRosterRow::moveDownRequested, this, &AgentRosterWidget::onRowMoveDown);
connect(row, &AgentRosterRow::editRequested, this, &AgentRosterWidget::onRowEdit);
@@ -621,11 +632,12 @@ void AgentRosterWidget::onAddClicked()
if (!m_factory)
return;
AgentSelectionDialog dialog(m_factory->configs(),
/*currentName*/ m_single ? m_names.value(0) : QString{},
m_factory.data(),
m_presetTags,
Core::ICore::dialogParent());
AgentSelectionDialog dialog(
m_factory->configs(),
/*currentName*/ m_single ? m_names.value(0) : QString{},
m_factory.data(),
m_presetTags,
Core::ICore::dialogParent());
if (dialog.exec() != QDialog::Accepted)
return;
const QString picked = dialog.selectedName();

View File

@@ -31,20 +31,12 @@ class AgentRosterWidget : public QWidget
public:
explicit AgentRosterWidget(QWidget *parent = nullptr);
void setSlot(
const QString &title,
const QString &hint,
const QStringList &presetTags = {});
void setSlot(const QString &title, const QString &hint, const QStringList &presetTags = {});
void setRoster(const QStringList &names, AgentFactory *factory);
void setRoutingContext(const AgentRouter::Context &ctx);
// When false, the list is an unordered set: no move arrows, no position
// numbers, no "first matching" routing hint. Used by pipelines where the
// user — not a router — chooses the agent (e.g. the chat picker).
void setOrderable(bool orderable);
// When true, the card holds at most one agent: "Add" becomes "Change",
// selecting replaces the current entry, and the routing footer is hidden.
// Implies a non-orderable list. Used by single-agent pipelines.
void setSingle(bool single);
[[nodiscard]] QStringList roster() const { return m_names; }

View File

@@ -4,8 +4,8 @@
#include "AgentSelectionDialog.hpp"
#include "PipelinesConfig.hpp"
#include "Pill.hpp"
#include "PipelinesConfig.hpp"
#include "SettingsTr.hpp"
#include "TagFilterStrip.hpp"
@@ -13,6 +13,7 @@
#include <AgentFactory.hpp>
#include <algorithm>
#include <QDialogButtonBox>
#include <QEnterEvent>
#include <QEvent>
@@ -27,12 +28,9 @@
#include <QSet>
#include <QTimer>
#include <QVBoxLayout>
#include <algorithm>
namespace QodeAssist::Settings {
// -- ListRowCard -------------------------------------------------------
ListRowCard::ListRowCard(QWidget *parent)
: QFrame(parent)
{
@@ -128,16 +126,13 @@ void ListRowCard::applyTheme()
.arg(bg, bd));
}
// -- AgentRowCard ------------------------------------------------------
AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
: ListRowCard(parent)
{
setItemName(cfg.name);
setItemTags(cfg.tags);
QStringList haystack{cfg.name, cfg.providerInstance, cfg.model,
cfg.description, cfg.systemPrompt,
cfg.endpoint};
QStringList haystack{
cfg.name, cfg.providerInstance, cfg.model, cfg.description, cfg.systemPrompt, cfg.endpoint};
haystack += cfg.tags;
buildSearchHaystack(haystack);
@@ -249,8 +244,6 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
setToolTip(tooltip.trimmed());
}
// -- ProviderSection ---------------------------------------------------
ProviderSection::ProviderSection(const QString &name, QWidget *parent)
: QWidget(parent)
{
@@ -337,8 +330,6 @@ bool ProviderSection::eventFilter(QObject *watched, QEvent *event)
return QWidget::eventFilter(watched, event);
}
// -- AgentSelectionDialog ----------------------------------------------
AgentSelectionDialog::AgentSelectionDialog(
const std::vector<AgentConfig> &configs,
const QString &currentName,
@@ -380,10 +371,10 @@ AgentSelectionDialog::AgentSelectionDialog(
m_resultCount->setPalette(rp);
}
auto *expandAll = new QLabel(
QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Expand all")), this);
auto *collapseAll = new QLabel(
QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Collapse all")), this);
auto *expandAll
= new QLabel(QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Expand all")), this);
auto *collapseAll
= new QLabel(QStringLiteral("<a href=\"#\">%1</a>").arg(Tr::tr("Collapse all")), this);
auto *controlsRow = new QHBoxLayout;
controlsRow->setContentsMargins(2, 0, 2, 0);
@@ -425,8 +416,9 @@ AgentSelectionDialog::AgentSelectionDialog(
preset.insert(tag);
m_tagStrip->setAvailableTags(tagCounts, preset);
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this,
[this](const QSet<QString> &) { applyFilters(); });
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this, [this](const QSet<QString> &) {
applyFilters();
});
connect(expandAll, &QLabel::linkActivated, this, [this](const QString &) {
setAllExpanded(true);
});
@@ -436,8 +428,7 @@ AgentSelectionDialog::AgentSelectionDialog(
rebuild(currentName);
connect(m_filter, &QLineEdit::textChanged, this,
[this](const QString &) { applyFilters(); });
connect(m_filter, &QLineEdit::textChanged, this, [this](const QString &) { applyFilters(); });
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
@@ -486,7 +477,8 @@ void AgentSelectionDialog::rebuild(const QString &currentName)
QMap<QString, std::vector<const AgentConfig *>> byProvider;
for (const auto &cfg : configs) {
if (cfg.hidden) continue; // hidden profiles stay loaded but don't surface in the picker
if (cfg.hidden)
continue;
const QString key = cfg.providerInstance.isEmpty()
? Tr::tr("(Unknown provider instance)")
: cfg.providerInstance;
@@ -565,8 +557,7 @@ void AgentSelectionDialog::applyFilters()
if (m_emptyLabel)
m_emptyLabel->setVisible(total == 0);
if (m_resultCount)
m_resultCount->setText(total == 0 ? tr("No matches")
: tr("%n agent(s)", nullptr, total));
m_resultCount->setText(total == 0 ? tr("No matches") : tr("%n agent(s)", nullptr, total));
if (m_tagStrip) {
QMap<QString, int> liveCounts;

View File

@@ -4,6 +4,7 @@
#pragma once
#include <vector>
#include <QDialog>
#include <QFont>
#include <QFrame>
@@ -11,7 +12,6 @@
#include <QPalette>
#include <QSet>
#include <QStringList>
#include <vector>
#include <AgentConfig.hpp>
@@ -142,7 +142,6 @@ public:
void setExpanded(bool expanded);
const QList<ListRowCard *> &cards() const { return m_cards; }
// returns number of visible cards
int applyFilter(const QString &needle, const QSet<QString> &activeTags);
protected:
@@ -189,7 +188,7 @@ private:
QString m_selectedName;
AgentFactory *m_agentFactory = nullptr;
std::vector<AgentConfig> m_localConfigs; // fallback when no factory
std::vector<AgentConfig> m_localConfigs;
QStringList m_presetTags;
};

View File

@@ -87,9 +87,10 @@ void Pill::applyTheme()
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const PillTone t = toneFor(m_kind);
setStyleSheet(QStringLiteral("QLabel { background-color:%1; color:%2;"
" border:1px solid %3; border-radius:3px;"
" padding:1px 7px; font-size:11px; }")
setStyleSheet(QStringLiteral(
"QLabel { background-color:%1; color:%2;"
" border:1px solid %3; border-radius:3px;"
" padding:1px 7px; font-size:11px; }")
.arg(cssColor(t.bg), cssColor(t.fg), cssColor(t.border)));
}

View File

@@ -9,8 +9,6 @@
namespace QodeAssist::Settings {
// Small rounded chip. Theme-aware: recolors itself on palette/style changes.
// All colors derive from the active Qt Creator theme (Utils::Theme).
class Pill : public QLabel
{
Q_OBJECT

View File

@@ -18,7 +18,6 @@
#include "Logger.hpp"
#include "TomlWriter.hpp"
#include <AgentFactory.hpp>
namespace QodeAssist::Settings {
@@ -45,8 +44,6 @@ QString toSingleString(const toml::node *node, const QString &slotKey, bool *sch
return {};
if (const auto *s = node->as_string())
return trimAndCap(QString::fromStdString(s->get()));
// Backward compatibility: older pipelines.toml stored these slots as an
// ordered array. Collapse to the first usable name.
if (const auto *arr = node->as_array()) {
for (size_t i = 0; i < arr->size(); ++i) {
if (const auto *s = (*arr)[i].as_string()) {
@@ -115,19 +112,53 @@ void fillMissingFromDefaults(PipelineRosters &r, const toml::table &section)
r.quickRefactor = defs.quickRefactor;
}
struct CacheState
{
PipelinesLoadResult result;
QDateTime mtime;
qint64 size = -1;
bool valid = false;
};
CacheState &cacheState()
{
static CacheState s;
return s;
}
QString &filePathOverride()
{
static QString p;
return p;
}
} // namespace
PipelineRosters PipelineRosters::defaults()
{
return PipelineRosters{};
return PipelineRosters{
{QStringLiteral("Ollama Completion — FIM")},
{QStringLiteral("Ollama Chat — Simple"),
QStringLiteral("Ollama Chat — Thinking"),
QStringLiteral("Ollama Chat — Gemma 4")},
QStringLiteral("Ollama Compression — 8 GB"),
QStringLiteral("Ollama Quick Refactor — Simple")};
}
QString PipelinesConfig::filePath()
{
if (!filePathOverride().isEmpty())
return filePathOverride();
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/pipelines.toml"))
.toFSPathString();
}
void PipelinesConfig::setFilePathForTests(const QString &path)
{
filePathOverride() = path;
cacheState() = {};
}
PipelinesLoadResult PipelinesConfig::load()
{
PipelinesLoadResult result;
@@ -206,19 +237,18 @@ PipelinesLoadResult PipelinesConfig::load()
PipelinesLoadResult PipelinesConfig::loadCached()
{
static PipelinesLoadResult cached;
static QDateTime cachedMTime;
static bool valid = false;
auto &cache = cacheState();
const QFileInfo info(filePath());
const QDateTime mtime = info.exists() ? info.lastModified() : QDateTime();
if (valid && mtime == cachedMTime)
return cached;
const qint64 size = info.exists() ? info.size() : -1;
if (cache.valid && mtime == cache.mtime && size == cache.size)
return cache.result;
cached = load();
cachedMTime = mtime;
valid = true;
return cached;
cache.result = load();
cache.mtime = mtime;
cache.size = size;
cache.valid = true;
return cache.result;
}
bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
@@ -236,15 +266,14 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
TomlSerializer::TomlWriter w;
w.writeComment(QStringLiteral("QodeAssist pipelines — which agent each feature uses."));
w.writeComment(QStringLiteral(
"code_completion: ordered list; the router walks it top-down and uses"));
w.writeComment(QStringLiteral(
" the first agent whose match rules fit the current file/project."));
w.writeComment(QStringLiteral(
"chat_assistant: agents offered in the chat picker (order irrelevant —"));
w.writeComment(
QStringLiteral("code_completion: ordered list; the router walks it top-down and uses"));
w.writeComment(
QStringLiteral(" the first agent whose match rules fit the current file/project."));
w.writeComment(
QStringLiteral("chat_assistant: agents offered in the chat picker (order irrelevant —"));
w.writeComment(QStringLiteral(" you choose one in the UI)."));
w.writeComment(QStringLiteral(
"chat_compression / quick_refactor: a single agent name."));
w.writeComment(QStringLiteral("chat_compression / quick_refactor: a single agent name."));
w.writeBlankLine();
w.writeTableHeader(QString::fromUtf8(kSection));
w.writeStringArray(QString::fromUtf8(kCodeCompletion), rosters.codeCompletion);
@@ -276,56 +305,8 @@ bool PipelinesConfig::save(const PipelineRosters &rosters, QString *errorOut)
*errorOut = out.errorString();
return false;
}
return true;
}
bool PipelinesConfig::validate(const QodeAssist::AgentFactory &factory, QString *errorOut)
{
PipelinesLoadResult lr = load();
PipelineRosters &r = lr.rosters;
bool changed = false;
auto fix = [&](QStringList &current) {
QStringList kept;
kept.reserve(current.size());
for (const QString &raw : current) {
const QString name = trimAndCap(raw);
if (name.isEmpty() || kept.contains(name))
continue;
if (factory.configByName(name))
kept.append(name);
}
if (kept != current) {
current = std::move(kept);
changed = true;
}
};
auto fixOne = [&](QString &current) {
const QString name = trimAndCap(current);
const QString next = (!name.isEmpty() && factory.configByName(name)) ? name : QString();
if (next != current) {
current = next;
changed = true;
}
};
fix(r.codeCompletion);
fix(r.chatAssistant);
fixOne(r.chatCompression);
fixOne(r.quickRefactor);
if (!changed && lr.status == PipelinesLoadStatus::Ok)
return true;
QString saveErr;
if (!save(r, &saveErr)) {
const QString msg = QStringLiteral("failed to persist after validation: %1").arg(saveErr);
LOG_MESSAGE(QStringLiteral("[Pipelines] %1").arg(msg));
if (errorOut)
*errorOut = msg;
return false;
}
cacheState() = {};
PipelinesNotifier::instance()->notifyChanged();
return true;
}

View File

@@ -4,24 +4,35 @@
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
namespace QodeAssist {
class AgentFactory;
}
namespace QodeAssist::Settings {
class PipelinesNotifier : public QObject
{
Q_OBJECT
public:
static PipelinesNotifier *instance()
{
static PipelinesNotifier notifier;
return &notifier;
}
void notifyChanged() { emit pipelinesChanged(); }
signals:
void pipelinesChanged();
private:
PipelinesNotifier() = default;
};
struct PipelineRosters
{
// Code completion is auto-routed: the router walks this ordered list at
// request time and uses the first agent whose match rules fit the file.
QStringList codeCompletion;
// Chat is user-driven: this is an unordered allow-list of the agents
// offered in the chat picker. The user picks; no routing happens.
QStringList chatAssistant;
// Compression and quick refactor each use a single fixed agent.
QString chatCompression;
QString quickRefactor;
@@ -48,8 +59,7 @@ public:
[[nodiscard]] static bool save(const PipelineRosters &rosters, QString *errorOut = nullptr);
[[nodiscard]] static bool validate(
const QodeAssist::AgentFactory &factory, QString *errorOut = nullptr);
static void setFilePathForTests(const QString &path);
};
} // namespace QodeAssist::Settings

View File

@@ -46,8 +46,6 @@ nlohmann::json buildContextJson(const ContextData &context)
ctx["files_metadata"] = std::move(files);
}
// tool_result blocks only carry the tool_use_id; resolve the originating
// tool name so templates (e.g. Google's functionResponse.name) can emit it.
QHash<QString, QString> toolNameById;
if (context.history) {
for (const auto &msg : context.history.value())
@@ -89,8 +87,7 @@ nlohmann::json buildContextJson(const ContextData &context)
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);
nlohmann::json parsedInput = nlohmann::json::parse(inputStr, nullptr, false);
if (parsedInput.is_discarded()) {
if (!b.toolInput.isEmpty()) {
qWarning("[QodeAssist] tool_use '%s' has unparseable input "
@@ -141,12 +138,6 @@ nlohmann::json buildContextJson(const ContextData &context)
return data;
}
// JSON-aware removal of trailing commas (a `,` immediately followed, after
// optional whitespace, by `}` or `]`). Body partials emit an unconditional
// comma after every array element / object member; this pass deletes the
// dangling one before the closing bracket so the result parses as strict
// JSON. String literals are skipped, so commas inside string values (e.g. a
// tool result containing "],") are never touched.
std::string stripTrailingCommas(const std::string &in)
{
std::string out;
@@ -176,23 +167,19 @@ std::string stripTrailingCommas(const std::string &in)
&& (in[j] == ' ' || in[j] == '\t' || in[j] == '\n' || in[j] == '\r'))
++j;
if (j < in.size() && (in[j] == '}' || in[j] == ']'))
continue; // drop this comma
continue;
}
out.push_back(c);
}
return out;
}
// Install a sandboxed `{% include %}` resolver. Includes resolve only against
// the given roots (bundled qrc partials, then the user agent's own dir); names
// containing ".." or starting with "/" are rejected. The included partial is
// parsed in the same environment, so its own includes/callbacks resolve too.
void setIncludeResolver(inja::Environment &env, std::vector<QString> roots)
{
inja::Environment *envPtr = &env;
env.set_include_callback(
[envPtr, roots = std::move(roots)](
const std::filesystem::path &, const std::string &name) -> inja::Template {
[envPtr, roots = std::move(roots)](const std::filesystem::path &, const std::string &name)
-> inja::Template {
const QString rel = QString::fromStdString(name);
if (rel.contains(QStringLiteral("..")) || rel.startsWith(QLatin1Char('/'))) {
throw inja::FileError("include rejected (path traversal): '" + name + "'");
@@ -208,24 +195,14 @@ void setIncludeResolver(inja::Environment &env, std::vector<QString> roots)
void registerStandardCallbacks(inja::Environment &env)
{
// `{% include %}` resolution is wired per-instance in fromConfig() via a
// whitelisted callback; disable inja's own filesystem search so the only
// path is our sandboxed resolver.
env.set_search_included_templates_in_files(false);
// 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();
});
// Returns the subset of a content_blocks array whose "type" equals the
// second argument. Lets templates build provider-specific structures (e.g.
// OpenAI message-level tool_calls / tool result messages) from a filtered
// list with clean loop.is_first/is_last comma handling.
env.add_callback("filter_by_type", 2, [](inja::Arguments &args) -> nlohmann::json {
const nlohmann::json &blocks = *args.at(0);
const std::string type = args.at(1)->get<std::string>();
@@ -261,58 +238,26 @@ void registerStandardCallbacks(inja::Environment &env)
}
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;
});
}
// A representative context for the load-time dry run: it populates every key a
// body/partial might touch (system_prompt, prefix, suffix, and a history that
// includes text, tool_use, tool_result and image blocks) so validation
// exercises all branches without tripping on missing variables.
ContextData makeValidationContext()
{
const QString hostile = QStringLiteral("va\"li\\da\ntion");
ContextData ctx;
ctx.systemPrompt = QStringLiteral("validation");
ctx.prefix = QStringLiteral("prefix");
ctx.suffix = QStringLiteral("suffix");
ctx.systemPrompt = QStringLiteral("system ") + hostile;
ctx.prefix = QStringLiteral("prefix ") + hostile;
ctx.suffix = QStringLiteral("suffix ") + hostile;
QVector<Message> history;
history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello")));
history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello ") + hostile));
Message asst;
asst.role = QStringLiteral("assistant");
{
ContentBlockEntry th;
th.kind = ContentBlockEntry::Kind::Thinking;
th.thinking = QStringLiteral("reasoning");
th.thinking = QStringLiteral("reasoning ") + hostile;
th.signature = QStringLiteral("sig");
asst.blocks.append(th);
ContentBlockEntry rth;
@@ -321,13 +266,13 @@ ContextData makeValidationContext()
asst.blocks.append(rth);
ContentBlockEntry t;
t.kind = ContentBlockEntry::Kind::Text;
t.text = QStringLiteral("hi");
t.text = QStringLiteral("hi ") + hostile;
asst.blocks.append(t);
ContentBlockEntry tu;
tu.kind = ContentBlockEntry::Kind::ToolUse;
tu.toolUseId = QStringLiteral("call_1");
tu.toolName = QStringLiteral("read_file");
tu.toolInput = QJsonObject{{QStringLiteral("path"), QStringLiteral("x")}};
tu.toolInput = QJsonObject{{QStringLiteral("path"), hostile}};
asst.blocks.append(tu);
}
history.append(asst);
@@ -338,7 +283,7 @@ ContextData makeValidationContext()
ContentBlockEntry tr;
tr.kind = ContentBlockEntry::Kind::ToolResult;
tr.toolUseId = QStringLiteral("call_1");
tr.result = QStringLiteral("ok");
tr.result = QStringLiteral("ok ") + hostile;
toolMsg.blocks.append(tr);
}
history.append(toolMsg);
@@ -348,7 +293,7 @@ ContextData makeValidationContext()
{
ContentBlockEntry te;
te.kind = ContentBlockEntry::Kind::Text;
te.text = QStringLiteral("look");
te.text = QStringLiteral("look ") + hostile;
imgMsg.blocks.append(te);
ContentBlockEntry im;
im.kind = ContentBlockEntry::Kind::Image;
@@ -391,11 +336,11 @@ std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
registerStandardCallbacks(tpl->m_env);
setIncludeResolver(tpl->m_env, tpl->m_partialRoots);
// Dry-run against a representative context: catches jinja syntax errors,
// unknown callbacks and missing partials at load time instead of on first send.
if (!tpl->renderBody(makeValidationContext())) {
setError(QStringLiteral("Agent '%1' [body] failed to render to valid JSON "
"(see log)").arg(cfg.name));
setError(QStringLiteral(
"Agent '%1' [body] failed to render to valid JSON "
"(see log)")
.arg(cfg.name));
return nullptr;
}
return tpl;
@@ -403,11 +348,6 @@ std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
namespace {
// Render one body value. A string containing jinja is rendered and its output
// spliced in as raw JSON; a plain string and any scalar pass through unchanged;
// objects/arrays recurse. A jinja string that renders to nothing sets `omit`
// so the caller drops the key. Returns false on render / JSON-parse failure.
// The caller must hold the render lock (inja's env is not re-entrant).
bool renderValue(
inja::Environment &env,
const QString &tplName,
@@ -463,8 +403,8 @@ bool renderValue(
try {
rendered = env.render(s.toStdString(), data);
} catch (const std::exception &e) {
qWarning("[QodeAssist] Template '%s' field render failed: %s",
qUtf8Printable(tplName), e.what());
qWarning(
"[QodeAssist] Template '%s' field render failed: %s", qUtf8Printable(tplName), e.what());
return false;
}
@@ -474,17 +414,17 @@ bool renderValue(
return true;
}
// Wrap so ANY JSON value (array/object/string/number) parses via QJsonDocument.
const std::string wrapped = "{\"v\":" + rendered + "}";
QJsonParseError perr;
const QJsonDocument doc = QJsonDocument::fromJson(QByteArray::fromStdString(wrapped), &perr);
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
const QString snippet = QString::fromStdString(rendered).left(500);
qWarning("[QodeAssist] Template '%s' field produced invalid JSON: %s\n"
"--- rendered (truncated) ---\n%s",
qUtf8Printable(tplName),
qUtf8Printable(perr.errorString()),
qUtf8Printable(snippet));
qWarning(
"[QodeAssist] Template '%s' field produced invalid JSON: %s\n"
"--- rendered (truncated) ---\n%s",
qUtf8Printable(tplName),
qUtf8Printable(perr.errorString()),
qUtf8Printable(snippet));
return false;
}
out = doc.object().value(QStringLiteral("v"));
@@ -519,13 +459,7 @@ std::optional<QJsonObject> JsonPromptTemplate::renderBody(const ContextData &con
return request;
}
void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const
{
mergeRenderedBody(request, renderBody(context));
}
bool JsonPromptTemplate::buildFullRequest(
QJsonObject &request, const ContextData &context) const
bool JsonPromptTemplate::buildFullRequest(QJsonObject &request, const ContextData &context) const
{
return mergeRenderedBody(request, renderBody(context));
}

View File

@@ -22,28 +22,17 @@ 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; }
void prepareRequest(QJsonObject &request, const ContextData &context) const override;
[[nodiscard]] bool buildFullRequest(
QJsonObject &request, const ContextData &context) const override;
@@ -54,21 +43,8 @@ private:
QString m_name;
QString m_description;
// The literal request body, as a deep-mergeable object. String values
// that contain jinja are rendered and spliced as JSON at request time;
// literal strings and scalars pass through unchanged.
QJsonObject m_body;
// Roots searched (in order) by the `{% include %}` resolver. The first
// is the bundled qrc partials prefix; an optional second is the user
// agent's own directory, so user profiles can ship their own partials.
std::vector<QString> m_partialRoots;
// 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;
mutable std::mutex m_renderMutex;
};

View File

@@ -27,15 +27,10 @@ public:
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;
[[nodiscard]] virtual bool buildFullRequest(
QJsonObject &request, const ContextData &context) const
{
prepareRequest(request, context);
return true;
}
[[nodiscard]] virtual bool buildFullRequest(QJsonObject &request, const ContextData &context) const
= 0;
};
} // namespace QodeAssist::Templates

View File

@@ -52,11 +52,31 @@ void TomlWriter::writeTableHeader(const QString &name)
m_out += QLatin1String("]\n");
}
namespace {
bool isBareKey(const QString &key)
{
if (key.isEmpty())
return false;
for (QChar c : key) {
if (!c.isLetterOrNumber() && c != QLatin1Char('_') && c != QLatin1Char('-'))
return false;
if (c.unicode() > 0x7f)
return false;
}
return true;
}
} // namespace
void TomlWriter::writeKeyPrefix(const QString &key)
{
m_out += key;
if (m_keyColumnWidth > key.size())
m_out += QString(m_keyColumnWidth - key.size(), QLatin1Char(' '));
const QString rendered = isBareKey(key)
? key
: QLatin1Char('"') + escapeBasic(key) + QLatin1Char('"');
m_out += rendered;
if (m_keyColumnWidth > rendered.size())
m_out += QString(m_keyColumnWidth - rendered.size(), QLatin1Char(' '));
m_out += QLatin1String(" = ");
}

View File

@@ -22,8 +22,8 @@ public:
void setKeyColumnWidth(int width) { m_keyColumnWidth = width; }
void writeBlankLine();
void writeComment(const QString &line); // "# line\n"
void writeTableHeader(const QString &name); // "[name]\n"
void writeComment(const QString &line);
void writeTableHeader(const QString &name);
void writeString(const QString &key, const QString &value);
void writeBool(const QString &key, bool value);