refactor: Finalize agent template

This commit is contained in:
Petr Mironychev
2026-06-03 17:28:50 +02:00
parent 98a618cf87
commit c151c5030b
57 changed files with 1737 additions and 393 deletions

View File

@@ -81,8 +81,6 @@ Session::Session(Agent *agent, ConversationHistory *externalHistory, QObject *pa
m_router = new ResponseRouter(client, m_history, this);
connect(m_router, &ResponseRouter::event, this, &Session::onRouterEvent);
m_systemPrompt->setLayer(QStringLiteral("agent.role"), m_agent->config().role);
}
Session::~Session()
@@ -134,12 +132,13 @@ QString Session::renderAgentContext() const
if (!m_agent)
return {};
const auto &cfg = m_agent->config();
if (cfg.context.isEmpty())
if (cfg.systemPrompt.isEmpty())
return {};
QString err;
QString rendered = Templates::ContextRenderer::render(cfg.context, m_contextBindings, &err);
QString rendered
= Templates::ContextRenderer::render(cfg.systemPrompt, m_contextBindings, &err);
if (!err.isEmpty())
qWarning("[QodeAssist] agent.context render failed: %s", qUtf8Printable(err));
qWarning("[QodeAssist] agent.system render failed: %s", qUtf8Printable(err));
return rendered;
}
@@ -231,9 +230,9 @@ LLMQore::RequestID Session::dispatch(
const QString renderedContext = renderAgentContext();
if (renderedContext.isEmpty())
m_systemPrompt->clearLayer(QStringLiteral("agent.context"));
m_systemPrompt->clearLayer(QStringLiteral("agent.system"));
else
m_systemPrompt->setLayer(QStringLiteral("agent.context"), renderedContext);
m_systemPrompt->setLayer(QStringLiteral("agent.system"), renderedContext);
Templates::ContextData ctx = toLegacyContext();
QJsonObject payload{{QStringLiteral("model"), cfg.model}};

View File

@@ -34,9 +34,8 @@ QString AgentConfig::validate(const AgentConfig &config)
return QStringLiteral("Agent config '%1' has no model").arg(config.name);
if (config.endpoint.isEmpty())
return QStringLiteral("Agent config '%1' has no endpoint").arg(config.name);
if (config.messageFormat.isEmpty()) {
return QStringLiteral("Agent config '%1' has no [template].message_format")
.arg(config.name);
if (config.body.isEmpty()) {
return QStringLiteral("Agent config '%1' has no [body]").arg(config.name);
}
return {};
}

View File

@@ -19,7 +19,7 @@ struct AgentConfig
QString providerInstance;
QString model;
QString endpoint;
QString role;
QString systemPrompt;
QStringList tags;
struct Match
@@ -40,10 +40,7 @@ struct AgentConfig
bool enableThinking = false;
bool enableTools = false;
QString messageFormat;
QJsonObject sampling;
QJsonObject thinking;
QString context;
QJsonObject body;
QString extendsName;
bool abstract = false;
bool hidden = false;

View File

@@ -4,6 +4,11 @@
#include "AgentFactory.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QThread>
@@ -37,6 +42,7 @@ AgentFactory::AgentFactory(
, m_secrets(secrets)
{
::initAgentsResource();
loadModelOverrides();
reload();
}
@@ -169,7 +175,11 @@ Agent *AgentFactory::create(const QString &name, QObject *parent, QString *error
*cfg, m_instanceFactory.data(), m_secrets.data(), errorOut);
if (!provider)
return nullptr;
auto agent = std::make_unique<Agent>(*cfg, provider, /*parent=*/nullptr);
AgentConfig resolved = *cfg;
const QString modelOv = m_modelOverrides.value(resolved.name);
if (!modelOv.isEmpty())
resolved.model = modelOv;
auto agent = std::make_unique<Agent>(resolved, provider, /*parent=*/nullptr);
if (!agent->isValid()) {
if (errorOut)
*errorOut = agent->invalidReason();
@@ -193,6 +203,9 @@ Agent *AgentFactory::createFromFile(
*cfgOpt, m_instanceFactory.data(), m_secrets.data(), errorOut);
if (!provider)
return nullptr;
const QString modelOv = m_modelOverrides.value(cfgOpt->name);
if (!modelOv.isEmpty())
cfgOpt->model = modelOv;
auto agent = std::make_unique<Agent>(std::move(*cfgOpt), provider, /*parent=*/nullptr);
if (!agent->isValid()) {
if (errorOut) *errorOut = agent->invalidReason();
@@ -221,4 +234,55 @@ Providers::ProviderSecretsStore *AgentFactory::secretsStore() const noexcept
return m_secrets.data();
}
QString AgentFactory::modelOverride(const QString &agentName) const
{
return m_modelOverrides.value(agentName);
}
void AgentFactory::setModelOverride(const QString &agentName, const QString &model)
{
if (model.isEmpty())
m_modelOverrides.remove(agentName);
else
m_modelOverrides.insert(agentName, model);
saveModelOverrides();
}
namespace {
QString modelOverridesPath()
{
return Core::ICore::userResourcePath(QStringLiteral("qodeassist/config/agent_models.json"))
.toFSPathString();
}
} // namespace
void AgentFactory::loadModelOverrides()
{
m_modelOverrides.clear();
QFile f(modelOverridesPath());
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
return;
const QJsonObject obj = QJsonDocument::fromJson(f.readAll()).object();
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) {
const QString model = it.value().toString();
if (!model.isEmpty())
m_modelOverrides.insert(it.key(), model);
}
}
void AgentFactory::saveModelOverrides() const
{
const QString path = modelOverridesPath();
QDir().mkpath(QFileInfo(path).absolutePath());
QJsonObject obj;
for (auto it = m_modelOverrides.constBegin(); it != m_modelOverrides.constEnd(); ++it)
obj.insert(it.key(), it.value());
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
LOG_MESSAGE(QStringLiteral("[Agents] cannot write model overrides: %1").arg(path));
return;
}
f.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
}
} // namespace QodeAssist

View File

@@ -53,12 +53,22 @@ public:
void registerConfig(AgentConfig config);
void clear();
// Per-agent model chosen in QodeAssist settings. The agent TOML's `model`
// is only the default; an override here (keyed by agent name) wins and is
// applied when the agent is built. Empty model clears the override.
[[nodiscard]] QString modelOverride(const QString &agentName) const;
void setModelOverride(const QString &agentName, const QString &model);
[[nodiscard]] Providers::ProviderInstanceFactory *instanceFactory() const noexcept;
[[nodiscard]] Providers::ProviderSecretsStore *secretsStore() const noexcept;
private:
void loadModelOverrides();
void saveModelOverrides() const;
std::vector<AgentConfig> m_configs;
QHash<QString, qsizetype> m_indexByName;
QHash<QString, QString> m_modelOverrides;
QStringList m_errors;
QStringList m_warnings;
QPointer<Providers::ProviderInstanceFactory> m_instanceFactory;

View File

@@ -120,8 +120,7 @@ AgentConfig configFromMerged(const QJsonObject &obj)
cfg.providerInstance = obj.value("provider_instance").toString();
cfg.model = obj.value("model").toString();
cfg.endpoint = obj.value("endpoint").toString();
cfg.role = obj.value("role").toString();
cfg.context = obj.value("context").toString();
cfg.systemPrompt = obj.value("system_prompt").toString();
cfg.enableThinking = obj.value("enable_thinking").toBool(false);
cfg.enableTools = obj.value("enable_tools").toBool(false);
cfg.tags = stringArray(obj.value("tags"));
@@ -135,10 +134,7 @@ AgentConfig configFromMerged(const QJsonObject &obj)
cfg.abstract = obj.value("abstract").toBool(false);
cfg.hidden = obj.value("hidden").toBool(false);
const QJsonObject tpl = obj.value("template").toObject();
cfg.messageFormat = tpl.value("message_format").toString();
cfg.sampling = tpl.value("sampling").toObject();
cfg.thinking = tpl.value("thinking").toObject();
cfg.body = obj.value("body").toObject();
return cfg;
}

View File

@@ -1,11 +1,46 @@
<RCC>
<qresource prefix="/agents">
<file>partials/openai_messages.jinja</file>
<file>partials/openai_assistant.jinja</file>
<file>partials/openai_tool_results.jinja</file>
<file>partials/openai_user.jinja</file>
<file>partials/openai_image_content.jinja</file>
<file>partials/openai_responses_input.jinja</file>
<file>partials/anthropic_messages.jinja</file>
<file>partials/anthropic_image.jinja</file>
<file>partials/google_contents.jinja</file>
<file>partials/google_part.jinja</file>
<file>partials/ollama_messages.jinja</file>
<file>chat_base.toml</file>
<file>openai_base_chat.toml</file>
<file>openai_responses_base.toml</file>
<file>anthropic_base_chat.toml</file>
<file>google_base_chat.toml</file>
<file>ollama_base_chat.toml</file>
<file>ollama_base_fim.toml</file>
<file>openai_chat.toml</file>
<file>openai_compatible_chat.toml</file>
<file>openai_responses.toml</file>
<file>mistral_chat.toml</file>
<file>mistral_medium_chat.toml</file>
<file>mistral_reasoning_chat.toml</file>
<file>codestral_chat.toml</file>
<file>codestral_fim.toml</file>
<file>llamacpp_chat.toml</file>
<file>lmstudio_chat.toml</file>
<file>lmstudio_responses.toml</file>
<file>openrouter_chat.toml</file>
<file>ollama_openai_chat.toml</file>
<file>ollama_gemma4_e4b_chat.toml</file>
<file>ollama_codellama_7b_code_fim.toml</file>
<file>ollama_codellama_13b_qml_fim.toml</file>
<file>claude_sonnet_chat.toml</file>
<file>claude_sonnet46_chat.toml</file>
<file>claude_haiku45_chat.toml</file>
<file>claude_opus_max.toml</file>
<file>google_gemini_chat.toml</file>
</qresource>
</RCC>

View File

@@ -0,0 +1,19 @@
schema_version = 1
name = "Anthropic Base Chat"
description = "Anthropic Messages API request body (/v1/messages). Abstract — extend it and set model."
abstract = true
extends = "Chat Base"
provider_instance = "Claude"
endpoint = "/v1/messages"
enable_tools = true
tags = ["chat", "claude", "anthropic", "cloud"]
[body]
max_tokens = 8192
temperature = 1
system = """{% if existsIn(ctx, "system_prompt") %}{{ tojson(ctx.system_prompt) }}{% endif %}"""
messages = """
[ {% include "partials/anthropic_messages.jinja" %} ]
"""

View File

@@ -0,0 +1,16 @@
schema_version = 1
name = "Chat Base"
description = "Shared system prompt for coding-chat agents. Abstract — not selectable."
abstract = true
system_prompt = """
You are a helpful coding assistant integrated into Qt Creator.
Answer concisely. Prefer concrete diffs or minimal patches over rewriting
whole files. Use markdown code blocks with language tags.
{% if file_exists("${PROJECT_DIR}/README.md") %}
## Project README.md
{{ read_file("${PROJECT_DIR}/README.md") }}
{% endif %}
"""

View File

@@ -0,0 +1,14 @@
schema_version = 1
extends = "Anthropic Base Chat"
name = "Claude Haiku 4.5 Chat"
description = "Anthropic Claude Haiku 4.5 — fastest model with near-frontier intelligence; extended thinking (manual budget)."
model = "claude-haiku-4-5-20251001"
enable_thinking = true
tags = ["chat", "claude", "anthropic", "cloud", "haiku", "fast"]
[body]
max_tokens = 16000
thinking = { type = "enabled", budget_tokens = 4096 }

View File

@@ -0,0 +1,15 @@
schema_version = 1
extends = "Anthropic Base Chat"
name = "Claude Opus 4.8 Max"
description = "Anthropic Claude Opus 4.8 at maximum capability — adaptive thinking at max effort, 128k max output."
model = "claude-opus-4-8"
enable_thinking = true
tags = ["chat", "claude", "anthropic", "cloud", "opus", "max"]
[body]
max_tokens = 128000
thinking = { type = "adaptive", display = "summarized" }
output_config = { effort = "max" }

View File

@@ -0,0 +1,15 @@
schema_version = 1
extends = "Anthropic Base Chat"
name = "Claude Sonnet 4.6 Chat"
description = "Anthropic Claude Sonnet 4.6 — fast, capable coding chat with adaptive thinking."
model = "claude-sonnet-4-6"
enable_thinking = true
tags = ["chat", "claude", "anthropic", "cloud", "sonnet"]
[body]
max_tokens = 16000
thinking = { type = "adaptive", display = "summarized" }
output_config = { effort = "high" }

View File

@@ -1,77 +1,14 @@
schema_version = 1
extends = "Anthropic Base Chat"
name = "Claude Sonnet Chat"
description = "Anthropic Claude (Messages API) — coding chat assistant via the hosted Claude provider."
provider_instance = "Claude"
endpoint = "/v1/messages"
description = "Anthropic Claude Sonnet 4.6 (Messages API) — coding chat assistant with thinking."
model = "claude-sonnet-4-6"
role = """
You are a helpful coding assistant integrated into Qt Creator.
Answer concisely. When the user shares code, prefer concrete diffs or
minimal patches over rewriting whole files. Use markdown code blocks
with language tags so the IDE can render them.
"""
enable_thinking = true
enable_tools = true
tags = ["chat", "claude", "anthropic", "cloud"]
context = """
{%- set readme = read_file("${PROJECT_DIR}/README.md") -%}
{%- if length(readme) > 0 %}
## Project README.md
{{ readme }}
{%- endif %}
"""
[template]
message_format = """
{
{%- if existsIn(ctx, "system_prompt") %}
"system": {{ tojson(ctx.system_prompt) }},
{%- endif %}
"messages": [
{%- for msg in ctx.history %}
{
"role": {{ tojson(msg.role) }},
"content": [
{%- for b in msg.content_blocks %}
{%- if b.type == "image" %}
{
"type": "image",
"source": {
{%- if b.is_url %}
"type": "url",
"url": {{ tojson(b.data) }}
{%- else %}
"type": "base64",
"media_type": {{ tojson(b.media_type) }},
"data": {{ tojson(b.data) }}
{%- endif %}
}
}{% if not loop.is_last %},{% endif %}
{%- else %}
{{ tojson(b) }}{% if not loop.is_last %},{% endif %}
{%- endif %}
{%- endfor %}
]
}{% if not loop.is_last %},{% endif %}
{%- endfor %}
]
}
"""
[template.sampling]
max_tokens = 8192
temperature = 1
[template.thinking.overrides]
temperature = 1
[template.thinking.request_block.thinking]
type = "enabled"
budget_tokens = 4096
[body]
max_tokens = 8192
thinking = { type = "enabled", budget_tokens = 4096 }

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "Codestral Chat"
description = "Mistral Codestral (Chat Completions API) — coding chat assistant."
provider_instance = "Codestral"
endpoint = "/v1/chat/completions"
model = "codestral-latest"
tags = ["chat", "codestral", "mistral", "cloud"]

View File

@@ -0,0 +1,19 @@
schema_version = 1
name = "Codestral FIM"
description = "Mistral Codestral fill-in-the-middle code completion (/v1/fim/completions)."
provider_instance = "Mistral AI"
endpoint = "/v1/fim/completions"
model = "codestral-latest"
enable_thinking = false
enable_tools = false
tags = ["fim", "codestral", "mistral", "cloud", "completion"]
[body]
max_tokens = 256
temperature = 0.2
stream = true
prompt = """{{ tojson(ctx.prefix) }}"""
suffix = """{% if existsIn(ctx, "suffix") %}{{ tojson(ctx.suffix) }}{% endif %}"""

View File

@@ -0,0 +1,20 @@
schema_version = 1
name = "Google Base Chat"
description = "Google Gemini generateContent request body. Abstract — extend it and set model/endpoint."
abstract = true
extends = "Chat Base"
provider_instance = "Google AI"
enable_tools = true
tags = ["chat", "gemini", "google", "cloud"]
[body]
system_instruction = """{% if existsIn(ctx, "system_prompt") %}{ "parts": [ { "text": {{ tojson(ctx.system_prompt) }} } ] }{% endif %}"""
contents = """
[ {% include "partials/google_contents.jinja" %} ]
"""
[body.generationConfig]
maxOutputTokens = 8192
temperature = 1

View File

@@ -1,79 +1,15 @@
schema_version = 1
extends = "Google Base Chat"
name = "Gemini Chat"
description = "Google Gemini (generateContent API) — coding chat assistant via the hosted Google AI provider."
description = "Google Gemini 2.5 Flash (generateContent API) — coding chat with thinking."
provider_instance = "Google AI"
endpoint = "/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
model = "gemini-2.5-flash"
role = """
You are a helpful coding assistant integrated into Qt Creator.
Answer concisely. When the user shares code, prefer concrete diffs or
minimal patches over rewriting whole files. Use markdown code blocks
with language tags so the IDE can render them.
"""
endpoint = "/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
model = "gemini-2.5-flash"
enable_thinking = true
enable_tools = true
tags = ["chat", "gemini", "google", "cloud"]
context = """
{%- set readme = read_file("${PROJECT_DIR}/README.md") -%}
{%- if length(readme) > 0 %}
## Project README.md
{{ readme }}
{%- endif %}
"""
[template]
message_format = """
{
{%- if existsIn(ctx, "system_prompt") %}
"system_instruction": { "parts": [{ "text": {{ tojson(ctx.system_prompt) }} }] },
{%- endif %}
"contents": [
{%- for msg in ctx.history %}
{
"role": {% if msg.role == "assistant" %}"model"{% else %}"user"{% endif %},
"parts": [
{%- for b in msg.content_blocks %}
{%- if b.type == "text" %}
{ "text": {{ tojson(b.text) }} }
{%- else if b.type == "thinking" %}
{ "text": {{ tojson(b.thinking) }}, "thought": true, "thoughtSignature": {{ tojson(b.signature) }} }
{%- else if b.type == "tool_use" %}
{ "functionCall": { "name": {{ tojson(b.name) }}, "args": {{ tojson(b.input) }} } }
{%- else if b.type == "tool_result" %}
{ "functionResponse": { "name": {{ tojson(b.name) }}, "response": { "result": {{ tojson(b.content) }} } } }
{%- else if b.type == "image" %}
{%- if b.is_url %}
{ "file_data": { "mime_type": {{ tojson(b.media_type) }}, "file_uri": {{ tojson(b.data) }} } }
{%- else %}
{ "inline_data": { "mime_type": {{ tojson(b.media_type) }}, "data": {{ tojson(b.data) }} } }
{%- endif %}
{%- else %}
{ "text": "" }
{%- endif %}
{% if not loop.is_last %},{% endif %}
{%- endfor %}
]
}{% if not loop.is_last %},{% endif %}
{%- endfor %}
]
}
"""
[template.sampling.generationConfig]
maxOutputTokens = 8192
temperature = 1
[template.thinking.request_block.generationConfig]
temperature = 1
[body.generationConfig]
maxOutputTokens = 16000
[template.thinking.request_block.generationConfig.thinkingConfig]
includeThoughts = true
thinkingBudget = 8192
thinkingConfig = { includeThoughts = true, thinkingBudget = 8192 }

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "llama.cpp Chat"
description = "llama.cpp server (OpenAI-compatible Chat Completions) — local coding chat assistant."
provider_instance = "llama.cpp"
endpoint = "/v1/chat/completions"
model = "llama"
tags = ["chat", "llamacpp", "local"]

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "LM Studio Chat"
description = "LM Studio (Chat Completions API) — local coding chat assistant."
provider_instance = "LM Studio (Chat Completions)"
endpoint = "/v1/chat/completions"
model = "local-model"
tags = ["chat", "lmstudio", "local"]

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Responses Base"
name = "LM Studio Responses"
description = "LM Studio (Responses API) — local coding chat assistant."
provider_instance = "LM Studio (Responses API)"
endpoint = "/v1/responses"
model = "local-model"
tags = ["chat", "lmstudio", "responses", "local"]

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "Mistral Chat"
description = "Mistral Large (Chat Completions API) — coding chat assistant."
provider_instance = "Mistral AI"
endpoint = "/v1/chat/completions"
model = "mistral-large-latest"
tags = ["chat", "mistral", "cloud"]

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "Mistral Medium Chat"
description = "Mistral Medium 3.5 (Chat Completions API) — frontier coding/agentic chat."
provider_instance = "Mistral AI"
endpoint = "/v1/chat/completions"
model = "mistral-medium-latest"
tags = ["chat", "mistral", "medium", "cloud"]

View File

@@ -0,0 +1,12 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "Mistral Reasoning Chat"
description = "Mistral Magistral Medium — native chain-of-thought reasoning model."
provider_instance = "Mistral AI"
endpoint = "/v1/chat/completions"
model = "magistral-medium-latest"
enable_thinking = true
tags = ["chat", "mistral", "reasoning", "cloud"]

View File

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

View File

@@ -1,30 +1,18 @@
schema_version = 1
name = "Ollama FIM Base"
description = "Shared base for Ollama native FIM (/api/generate) profiles."
abstract = true
description = "Ollama native /api/generate FIM request body. Abstract — extend it and set model/prompt."
abstract = true
provider_instance = "Ollama (Native)"
endpoint = "/api/generate"
tags = ["ollama", "local", "fim"]
[template]
message_format = """
{
"prompt": {{ tojson(ctx.prefix) }},
"suffix": {{ tojson(ctx.suffix) }}
{%- if existsIn(ctx, "system_prompt") %},
"system": {{ tojson(ctx.system_prompt) }}
{%- endif %}
}
"""
[template.sampling]
[body]
stream = true
system = """{% if existsIn(ctx, "system_prompt") %}{{ tojson(ctx.system_prompt) }}{% endif %}"""
[template.sampling.options]
[body.options]
num_predict = 512
temperature = 0.2
top_p = 0.9

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "Ollama (OpenAI-compatible) Chat"
description = "Ollama via its OpenAI-compatible Chat Completions endpoint — local coding chat assistant."
provider_instance = "Ollama (OpenAI-compatible)"
endpoint = "/v1/chat/completions"
model = "qwen2.5-coder"
tags = ["chat", "ollama", "local"]

View File

@@ -0,0 +1,18 @@
schema_version = 1
name = "OpenAI Base Chat"
description = "OpenAI Chat Completions request body. Abstract — extend it and set provider/endpoint/model."
abstract = true
extends = "Chat Base"
provider_instance = "OpenAI (Chat Completions)"
endpoint = "/chat/completions"
enable_tools = true
tags = ["chat", "openai", "cloud"]
[body]
max_tokens = 8192
temperature = 0.7
messages = """
[ {% include "partials/openai_messages.jinja" %} ]
"""

View File

@@ -0,0 +1,7 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "OpenAI Chat"
description = "OpenAI Chat Completions — coding chat assistant (GPT-4o)."
model = "gpt-4o"

View File

@@ -0,0 +1,10 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "OpenAI Compatible Chat"
description = "Any OpenAI-compatible Chat Completions endpoint — set the model to match your server."
provider_instance = "OpenAI Compatible"
model = "default"
tags = ["chat", "openai", "compatible"]

View File

@@ -0,0 +1,7 @@
schema_version = 1
extends = "OpenAI Responses Base"
name = "OpenAI Responses"
description = "OpenAI Responses API (/responses) — coding chat assistant."
model = "gpt-4o"

View File

@@ -0,0 +1,19 @@
schema_version = 1
name = "OpenAI Responses Base"
description = "OpenAI Responses API request body (/responses). Abstract — extend it and set provider/endpoint/model."
abstract = true
extends = "Chat Base"
provider_instance = "OpenAI (Responses API)"
endpoint = "/responses"
enable_tools = true
tags = ["chat", "openai", "responses", "cloud"]
[body]
max_output_tokens = 8192
temperature = 0.7
instructions = """{% if existsIn(ctx, "system_prompt") %}{{ tojson(ctx.system_prompt) }}{% endif %}"""
input = """
[ {% include "partials/openai_responses_input.jinja" %} ]
"""

View File

@@ -0,0 +1,11 @@
schema_version = 1
extends = "OpenAI Base Chat"
name = "OpenRouter Chat"
description = "OpenRouter (OpenAI-compatible Chat Completions) — coding chat assistant."
provider_instance = "OpenRouter"
endpoint = "/chat/completions"
model = "openai/gpt-4o"
tags = ["chat", "openrouter", "cloud"]

View File

@@ -0,0 +1,9 @@
{
"type": "image",
"source":
{% if b.is_url %}
{ "type": "url", "url": {{ tojson(b.data) }} }
{% else %}
{ "type": "base64", "media_type": {{ tojson(b.media_type) }}, "data": {{ tojson(b.data) }} }
{% endif %}
},

View File

@@ -0,0 +1,12 @@
{% for msg in ctx.history %}
{
"role": {{ tojson(msg.role) }},
"content": [
{% for b in msg.content_blocks %}
{% if b.type == "image" %}{% include "partials/anthropic_image.jinja" %}
{% else %}{{ tojson(b) }},
{% endif %}
{% endfor %}
]
},
{% endfor %}

View File

@@ -0,0 +1,6 @@
{% for msg in ctx.history %}
{
"role": {% if msg.role == "assistant" %}"model"{% else %}"user"{% endif %},
"parts": [ {% for b in msg.content_blocks %}{% include "partials/google_part.jinja" %}{% endfor %} ]
},
{% endfor %}

View File

@@ -0,0 +1,17 @@
{% if b.type == "text" %}
{ "text": {{ tojson(b.text) }} },
{% else if b.type == "thinking" %}
{ "text": {{ tojson(b.thinking) }}, "thought": true, "thoughtSignature": {{ tojson(b.signature) }} },
{% else if b.type == "tool_use" %}
{ "functionCall": { "name": {{ tojson(b.name) }}, "args": {{ tojson(b.input) }} } },
{% else if b.type == "tool_result" %}
{ "functionResponse": { "name": {{ tojson(b.name) }}, "response": { "result": {{ tojson(b.content) }} } } },
{% else if b.type == "image" %}
{% if b.is_url %}
{ "file_data": { "mime_type": {{ tojson(b.media_type) }}, "file_uri": {{ tojson(b.data) }} } },
{% else %}
{ "inline_data": { "mime_type": {{ tojson(b.media_type) }}, "data": {{ tojson(b.data) }} } },
{% endif %}
{% else %}
{ "text": "" },
{% endif %}

View File

@@ -0,0 +1,16 @@
{% if existsIn(ctx, "system_prompt") %}
{ "role": "system", "content": {{ tojson(ctx.system_prompt) }} },
{% endif %}
{% for msg in ctx.history %}
{
"role": {{ tojson(msg.role) }},
"content": {{ tojson(msg.content) }}
{% if existsIn(msg, "images") %}
, "images": [
{% for img in msg.images %}
{{ tojson(img.data) }},
{% endfor %}
]
{% endif %}
},
{% endfor %}

View File

@@ -0,0 +1,19 @@
{% set tcalls = filter_by_type(msg.content_blocks, "tool_use") %}
{
"role": "assistant",
"content": {{ tojson(msg.content) }}
{% if length(tcalls) > 0 %}
, "tool_calls": [
{% for b in tcalls %}
{
"id": {{ tojson(b.id) }},
"type": "function",
"function": {
"name": {{ tojson(b.name) }},
"arguments": {{ tojson(tojson(b.input)) }}
}
},
{% endfor %}
]
{% endif %}
},

View File

@@ -0,0 +1,11 @@
[
{ "type": "text", "text": {{ tojson(msg.content) }} }
{% for img in msg.images %}
,
{% if img.is_url %}
{ "type": "image_url", "image_url": { "url": {{ tojson(img.data) }} } }
{% else %}
{ "type": "image_url", "image_url": { "url": "data:{{ img.media_type }};base64,{{ img.data }}" } }
{% endif %}
{% endfor %}
]

View File

@@ -0,0 +1,9 @@
{% if existsIn(ctx, "system_prompt") %}
{ "role": "system", "content": {{ tojson(ctx.system_prompt) }} },
{% endif %}
{% for msg in ctx.history %}
{% if msg.role == "assistant" %}{% include "partials/openai_assistant.jinja" %}
{% else if length(filter_by_type(msg.content_blocks, "tool_result")) > 0 %}{% include "partials/openai_tool_results.jinja" %}
{% else %}{% include "partials/openai_user.jinja" %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,30 @@
{% for msg in ctx.history %}
{% if msg.role == "assistant" %}
{% if msg.content != "" %}
{ "role": "assistant", "content": {{ tojson(msg.content) }} },
{% endif %}
{% for b in filter_by_type(msg.content_blocks, "tool_use") %}
{ "type": "function_call", "call_id": {{ tojson(b.id) }}, "name": {{ tojson(b.name) }}, "arguments": {{ tojson(tojson(b.input)) }} },
{% endfor %}
{% else if length(filter_by_type(msg.content_blocks, "tool_result")) > 0 %}
{% for b in filter_by_type(msg.content_blocks, "tool_result") %}
{ "type": "function_call_output", "call_id": {{ tojson(b.tool_use_id) }}, "output": {{ tojson(b.content) }} },
{% endfor %}
{% else %}
{% if existsIn(msg, "images") %}
{ "role": "user", "content": [
{ "type": "input_text", "text": {{ tojson(msg.content) }} }
{% for img in msg.images %}
,
{% if img.is_url %}
{ "type": "input_image", "detail": "auto", "image_url": {{ tojson(img.data) }} }
{% else %}
{ "type": "input_image", "detail": "auto", "image_url": "data:{{ img.media_type }};base64,{{ img.data }}" }
{% endif %}
{% endfor %}
] },
{% else %}
{ "role": "user", "content": {{ tojson(msg.content) }} },
{% endif %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,3 @@
{% for b in filter_by_type(msg.content_blocks, "tool_result") %}
{ "role": "tool", "tool_call_id": {{ tojson(b.tool_use_id) }}, "content": {{ tojson(b.content) }} },
{% endfor %}

View File

@@ -0,0 +1,5 @@
{% if existsIn(msg, "images") %}
{ "role": "user", "content": {% include "partials/openai_image_content.jinja" %} },
{% else %}
{ "role": "user", "content": {{ tojson(msg.content) }} },
{% endif %}

View File

@@ -125,7 +125,7 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
{
setItemName(cfg.name);
QStringList haystack{cfg.name, cfg.providerInstance, cfg.model,
cfg.description, cfg.role,
cfg.description, cfg.systemPrompt,
cfg.endpoint};
haystack += cfg.tags;
buildSearchHaystack(haystack);
@@ -234,8 +234,8 @@ AgentRowCard::AgentRowCard(const AgentConfig &cfg, QWidget *parent)
tooltip += cfg.description + QStringLiteral("\n\n");
if (!cfg.providerInstance.isEmpty())
tooltip += Tr::tr("Provider instance: %1\n").arg(cfg.providerInstance);
if (!cfg.role.isEmpty())
tooltip += Tr::tr("Role: %1\n").arg(cfg.role);
if (!cfg.systemPrompt.isEmpty())
tooltip += Tr::tr("System prompt: %1\n").arg(cfg.systemPrompt);
if (!cfg.endpoint.isEmpty())
tooltip += Tr::tr("Endpoint: %1\n").arg(cfg.endpoint);
setToolTip(tooltip.trimmed());

View File

@@ -5,6 +5,9 @@
#include "JsonPromptTemplate.hpp"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
@@ -138,19 +141,77 @@ 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;
out.reserve(in.size());
bool inString = false;
bool escaped = false;
for (std::size_t i = 0; i < in.size(); ++i) {
const char c = in[i];
if (inString) {
out.push_back(c);
if (escaped)
escaped = false;
else if (c == '\\')
escaped = true;
else if (c == '"')
inString = false;
continue;
}
if (c == '"') {
inString = true;
out.push_back(c);
continue;
}
if (c == ',') {
std::size_t j = i + 1;
while (j < in.size()
&& (in[j] == ' ' || in[j] == '\t' || in[j] == '\n' || in[j] == '\r'))
++j;
if (j < in.size() && (in[j] == '}' || in[j] == ']'))
continue; // drop this comma
}
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 {
const QString rel = QString::fromStdString(name);
if (rel.contains(QStringLiteral("..")) || rel.startsWith(QLatin1Char('/'))) {
throw inja::FileError("include rejected (path traversal): '" + name + "'");
}
for (const QString &root : roots) {
QFile f(root + QLatin1Char('/') + rel);
if (f.open(QIODevice::ReadOnly | QIODevice::Text))
return envPtr->parse(QString::fromUtf8(f.readAll()).toStdString());
}
throw inja::FileError("include not found in partials roots: '" + name + "'");
});
}
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()).
// `{% 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);
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
@@ -161,6 +222,23 @@ void registerStandardCallbacks(inja::Environment &env)
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>();
nlohmann::json result = nlohmann::json::array();
if (blocks.is_array()) {
for (const auto &b : blocks) {
if (b.is_object() && b.value("type", std::string{}) == type)
result.push_back(b);
}
}
return result;
});
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: ";
@@ -215,6 +293,66 @@ void registerStandardCallbacks(inja::Environment &env)
});
}
// 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()
{
ContextData ctx;
ctx.systemPrompt = QStringLiteral("validation");
ctx.prefix = QStringLiteral("prefix");
ctx.suffix = QStringLiteral("suffix");
QVector<Message> history;
history.append(Message::text(QStringLiteral("user"), QStringLiteral("hello")));
Message asst;
asst.role = QStringLiteral("assistant");
{
ContentBlockEntry t;
t.kind = ContentBlockEntry::Kind::Text;
t.text = QStringLiteral("hi");
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")}};
asst.blocks.append(tu);
}
history.append(asst);
Message toolMsg;
toolMsg.role = QStringLiteral("user");
{
ContentBlockEntry tr;
tr.kind = ContentBlockEntry::Kind::ToolResult;
tr.toolUseId = QStringLiteral("call_1");
tr.result = QStringLiteral("ok");
toolMsg.blocks.append(tr);
}
history.append(toolMsg);
Message imgMsg;
imgMsg.role = QStringLiteral("user");
{
ContentBlockEntry te;
te.kind = ContentBlockEntry::Kind::Text;
te.text = QStringLiteral("look");
imgMsg.blocks.append(te);
ContentBlockEntry im;
im.kind = ContentBlockEntry::Kind::Image;
im.imageData = QStringLiteral("AAAA");
im.mediaType = QStringLiteral("image/png");
imgMsg.blocks.append(im);
}
history.append(imgMsg);
ctx.history = history;
return ctx;
}
} // namespace
std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
@@ -224,97 +362,154 @@ std::unique_ptr<JsonPromptTemplate> JsonPromptTemplate::fromConfig(
if (error) *error = msg;
};
if (cfg.messageFormat.isEmpty()) {
setError(QStringLiteral("Agent '%1' has empty message_format").arg(cfg.name));
if (cfg.body.isEmpty()) {
setError(QStringLiteral("Agent '%1' has empty [body]").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;
tpl->m_body = cfg.body;
tpl->m_partialRoots.push_back(QStringLiteral(":/agents"));
if (cfg.isUserSource()) {
const QString dir = QFileInfo(cfg.sourcePath).absolutePath();
if (!dir.isEmpty())
tpl->m_partialRoots.push_back(dir);
}
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())));
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));
return nullptr;
}
return tpl;
}
std::optional<QJsonObject> JsonPromptTemplate::renderBody(const ContextData &context) const
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,
const QJsonValue &in,
const nlohmann::json &data,
QJsonValue &out,
bool &omit)
{
const nlohmann::json data = buildContextJson(context);
omit = false;
if (in.isObject()) {
QJsonObject obj;
const QJsonObject src = in.toObject();
for (auto it = src.constBegin(); it != src.constEnd(); ++it) {
QJsonValue v;
bool om = false;
if (!renderValue(env, tplName, it.value(), data, v, om))
return false;
if (!om)
obj.insert(it.key(), v);
}
out = obj;
return true;
}
if (in.isArray()) {
QJsonArray arr;
const QJsonArray src = in.toArray();
for (const QJsonValue &elem : src) {
QJsonValue v;
bool om = false;
if (!renderValue(env, tplName, elem, data, v, om))
return false;
if (!om)
arr.append(v);
}
out = arr;
return true;
}
if (!in.isString()) {
out = in;
return true;
}
const QString s = in.toString();
if (!s.contains(QStringLiteral("{{")) && !s.contains(QStringLiteral("{%"))) {
out = in;
return true;
}
std::string rendered;
try {
std::lock_guard<std::mutex> lock(m_renderMutex);
rendered = m_env.render(m_template, data);
rendered = env.render(s.toStdString(), data);
} catch (const std::exception &e) {
qWarning("[QodeAssist] Template '%s' render failed: %s",
qUtf8Printable(m_name),
e.what());
return std::nullopt;
qWarning("[QodeAssist] Template '%s' field render failed: %s",
qUtf8Printable(tplName), e.what());
return false;
}
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;
rendered = stripTrailingCommas(rendered);
if (QString::fromStdString(rendered).trimmed().isEmpty()) {
omit = true;
return true;
}
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;
// 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));
return false;
}
return doc.object();
out = doc.object().value(QStringLiteral("v"));
return true;
}
namespace {
bool mergeRenderedBody(QJsonObject &request, const std::optional<QJsonObject> &body)
{
if (!body)
return false;
for (auto it = body->constBegin(); it != body->constEnd(); ++it) {
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
std::optional<QJsonObject> JsonPromptTemplate::renderBody(const ContextData &context) const
{
const nlohmann::json data = buildContextJson(context);
std::lock_guard<std::mutex> lock(m_renderMutex);
QJsonObject request;
for (auto it = m_body.constBegin(); it != m_body.constEnd(); ++it) {
QJsonValue v;
bool omit = false;
if (!renderValue(m_env, m_name, it.value(), data, v, omit))
return std::nullopt;
if (!omit)
request.insert(it.key(), v);
}
return request;
}
void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData &context) const
{
mergeRenderedBody(request, renderBody(context));
@@ -323,27 +518,9 @@ void JsonPromptTemplate::prepareRequest(QJsonObject &request, const ContextData
bool JsonPromptTemplate::buildFullRequest(
QJsonObject &request,
const ContextData &context,
bool thinkingEnabled) const
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);
return mergeRenderedBody(request, renderBody(context));
}
} // namespace QodeAssist::Templates

View File

@@ -7,6 +7,7 @@
#include <memory>
#include <mutex>
#include <optional>
#include <vector>
#include <QJsonObject>
#include <QString>
@@ -50,27 +51,30 @@ public:
const ContextData &context,
bool thinkingEnabled = false) const override;
const QJsonObject &sampling() const { return m_sampling; }
private:
JsonPromptTemplate() = default;
std::optional<QJsonObject> renderBody(const ContextData &context) const;
void applySampling(QJsonObject &request, bool thinkingEnabled) const;
QString m_name;
QString m_description;
// 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;
inja::Template m_template;
mutable std::mutex m_renderMutex;
QJsonObject m_sampling;
QJsonObject m_thinking;
};
} // namespace QodeAssist::Templates