mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 10:59:30 -04:00
feat: Add agents and agents settings
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user