Files
CST-agent/app/src/core/AgentCore.cpp
T
WHC 82dee65942 v0.8.15: custom providers and model parameters under Advanced options
The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.

Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
  engine's own user layer, <config>/cst-agent/models.json:
  {"providers": {"myllm": {"api_base": ..., "key_env": ...,
                           "default_model": ..., "models": [...]}}}
  and reload the registry, so the provider is at once in the provider list,
  in the wire models[] and in the CLI's /provider -- the file a hand-written
  override would go into is the same one, so the GUI and an editor agree.
  The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
  name overrides that preset (a self-hosted endpoint, a proxy), everything
  else in the file (model rules) is kept, and a file that is not a JSON
  object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
  addProvider() / removeProvider() drive the two calls above.

Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
  max_tokens. 0 means the field is not sent at all -- an absent field is the
  provider's own default, which "temperature": 0 is not (deterministic
  decoding is a real choice), so cst_llm_build_body() only adds what was set.
  tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
  cst_settings_remember_params(); cst_settings_remember() carries them along
  with the provider choice, and cst_agent_create() reads them, so a CLI or
  TUI run sends what the window was set to. An all-zero config (the CLI)
  leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
  remembered parameters of the provider it lands on and a base URL that was
  edited in the dialog.

The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
  used to push the key field and the buttons out of the dialog. The model
  chips are filtered (a filter field appears past twelve) and capped at 40
  with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
  variable, default model, models; Save provider / Remove from models.json)
  and the model parameters (temperature, top-p, max tokens; empty = not
  sent). The fields follow the picked provider and are what gets saved, so a
  hand-typed edit is never lost to a stale property.

Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.

Version 0.8.15.

mode = coding
2026-10-11 13:26:02 +08:00

949 lines
32 KiB
C++

#include "AgentCore.h"
#include <QBuffer>
#include <QClipboard>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStringList>
#include <QThread>
#include <QVariantMap>
#include <cstdlib>
#include "cst_agent.h"
#include "cst_models.h"
#include "cst_provider_switch.h"
#include "cst_web_md.h"
/* --- the worker thread --------------------------------------------------- */
/* One turn: the whole cst_agent_run_turn_ex() call, on its own thread. The
* prompt is kept here so the pointer stays valid for the run. */
class Turn : public QThread {
public:
Turn(AgentCore *owner, const QString &prompt)
: QThread(owner), m_owner(owner), m_prompt(prompt.toUtf8()) {}
protected:
void run() override {
char err[512] = "";
int rc = cst_agent_run_turn_ex(m_owner->m_agent, m_prompt.constData(),
&m_owner->m_hooks, err, sizeof(err));
m_owner->finishTurn(rc, QString::fromUtf8(err));
}
private:
AgentCore *m_owner;
QByteArray m_prompt;
};
/* --- lifetime ------------------------------------------------------------ */
AgentCore::AgentCore(QObject *parent) : QObject(parent) {
memset(&m_cfg, 0, sizeof(m_cfg));
memset(&m_hooks, 0, sizeof(m_hooks));
m_hooks.on_status = hookStatus;
m_hooks.on_assistant_delta = hookAssistant;
m_hooks.on_think_delta = hookThink;
m_hooks.on_tool_event = hookToolEvent;
m_hooks.on_tool_result = hookToolResult;
m_hooks.on_todo = hookTodo;
m_hooks.on_permission = hookPermission;
m_hooks.on_ask_user = hookAskUser;
m_hooks.userdata = this;
}
AgentCore::~AgentCore() { close(); }
bool AgentCore::open(const QString &workspace, const QString &provider,
const QString &model, const QString &apiKey,
QString *err) {
close();
QDir dir(workspace);
if (!dir.exists() && !dir.mkpath(".")) {
if (err)
*err = QStringLiteral("cannot create %1").arg(workspace);
return false;
}
const QByteArray ws = QFileInfo(workspace).absoluteFilePath().toUtf8();
snprintf(m_cfg.workspace_root, sizeof(m_cfg.workspace_root), "%s",
ws.constData());
m_workspace = QString::fromUtf8(ws);
if (!provider.isEmpty())
snprintf(m_cfg.provider, sizeof(m_cfg.provider), "%s",
provider.toUtf8().constData());
if (!model.isEmpty())
snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", model.toUtf8().constData());
if (!apiKey.isEmpty())
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s",
apiKey.toUtf8().constData());
/* A window with no provider cannot do anything: fall back to the one the
* engine used last, then to its built-in default, and take that provider's
* base URL and model with it (a --model still wins). */
cst_settings_t st;
memset(&st, 0, sizeof(st));
const bool have_settings = cst_settings_load(&st) == 0;
if (m_cfg.provider[0] == 0) {
const char *pick = (have_settings && st.provider[0])
? st.provider
: (cst_provider_default()
? cst_provider_default()->name
: "");
if (pick && pick[0])
snprintf(m_cfg.provider, sizeof(m_cfg.provider), "%s", pick);
}
const cst_provider_t *preset = cst_provider_find(m_cfg.provider);
if (preset) {
if (m_cfg.api_base[0] == 0 && preset->api_base)
snprintf(m_cfg.api_base, sizeof(m_cfg.api_base), "%s", preset->api_base);
if (m_cfg.model[0] == 0) {
const char *remembered =
have_settings ? cst_settings_model(&st, m_cfg.provider) : nullptr;
const char *m =
(remembered && remembered[0]) ? remembered : preset->model;
if (m && m[0])
snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", m);
}
}
/* Sessions live in the workspace's store -- the same files the CLI and the
* WebUI use. Without this the engine would open session.jsonl in the
* process's working directory, next to a session list that stays empty. */
char sid[128] = "";
if (cst_agent_store_resolve_default(m_cfg.workspace_root, m_cfg.session_path,
sizeof(m_cfg.session_path), sid,
sizeof(sid)) != 0)
m_cfg.session_path[0] = 0; /* fall back to the engine's default */
/* The GUI answers itself: never let the engine decide behind its back. */
m_cfg.auto_approve = 0;
m_cfg.headless = 0;
if (!m_cfg.mode[0])
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE);
m_agent = cst_agent_create(&m_cfg);
if (!m_agent) {
if (err)
*err = QStringLiteral("cannot start the engine on %1").arg(m_workspace);
return false;
}
/* The session file is created by the engine; attachments live next to it. */
emit workspaceChanged();
emit configChanged();
return true;
}
/* The engine already renders markdown for the WebUI; the window uses the same
* renderer, so a table or a code fence looks the same in both front ends. */
QString AgentCore::renderMarkdown(const QString &text) const {
const QByteArray in = text.toUtf8();
char *html = cst_web_md_html(in.constData(), static_cast<size_t>(in.size()));
if (!html)
return text.toHtmlEscaped().replace(QLatin1Char('\n'),
QStringLiteral("<br>"));
const QString out = QString::fromUtf8(html);
free(html);
return out;
}
void AgentCore::close() {
if (!m_agent)
return;
if (m_running) {
cst_agent_request_cancel(m_agent);
answerPermission(CST_PERM_DENY);
if (m_turn)
m_turn->wait(15000);
}
m_closing = true;
cst_agent_destroy(m_agent);
m_agent = nullptr;
m_closing = false;
setRunning(false);
m_workspace.clear();
emit workspaceChanged();
}
/* --- turns --------------------------------------------------------------- */
void AgentCore::send(const QString &prompt) {
if (!m_agent || m_running || prompt.trimmed().isEmpty())
return;
setRunning(true);
m_turn = new Turn(this, prompt);
m_turn->start();
}
void AgentCore::cancel() {
if (m_agent)
cst_agent_request_cancel(m_agent);
}
void AgentCore::finishTurn(int rc, const QString &err) {
Turn *t = static_cast<Turn *>(sender());
post([this, rc, err] {
setRunning(false);
/* The prompt carried whatever was pending; the engine dropped it. */
m_attachNames.clear();
emit pendingChanged();
emit turnFinished(rc, err);
});
if (t) {
t->wait();
t->deleteLater();
}
m_turn = nullptr;
}
void AgentCore::setRunning(bool on) {
if (m_running == on)
return;
m_running = on;
emit runningChanged();
}
/* --- consent ------------------------------------------------------------- */
void AgentCore::answerPermission(int choice) {
QMutexLocker lock(&m_askMu);
if (!m_permPending)
return;
m_permAnswer = choice;
m_permPending = false;
m_askCv.wakeAll();
}
void AgentCore::answerQuestion(const QString &answer) {
QMutexLocker lock(&m_askMu);
if (!m_questionPending)
return;
m_answer = answer.toUtf8();
m_questionPending = false;
m_askCv.wakeAll();
}
int AgentCore::hookPermission(const char *tool, const char *detail,
const char *preview, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
if (!self || self->m_closing)
return CST_PERM_DENY;
if (self->m_autoApprove)
return CST_PERM_ONCE;
QMutexLocker lock(&self->m_askMu);
self->m_permAnswer = CST_PERM_DENY;
self->m_permPending = true;
const QString t = QString::fromUtf8(tool ? tool : "");
const QString d = QString::fromUtf8(detail ? detail : "");
const QString p = QString::fromUtf8(preview ? preview : "");
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
while (self->m_permPending && !self->m_closing)
self->m_askCv.wait(&self->m_askMu);
self->m_permPending = false;
return self->m_permAnswer;
}
int AgentCore::hookAskUser(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
if (!self || self->m_closing)
return -1;
if (self->m_autoApprove)
return -1;
QMutexLocker lock(&self->m_askMu);
self->m_questionPending = true;
self->m_answer.clear();
const QString q = QString::fromUtf8(question ? question : "");
const QString o = QString::fromUtf8(options ? options : "");
self->post([self, q, o] { emit self->questionAsked(q, o); });
while (self->m_questionPending && !self->m_closing)
self->m_askCv.wait(&self->m_askMu);
if (self->m_closing)
return -1;
snprintf(ans, ans_sz, "%s", self->m_answer.constData());
return 0;
}
/* --- settings ------------------------------------------------------------ */
void AgentCore::setMode(const QString &mode) {
const QByteArray m = mode.toUtf8();
if (!cst_mode_valid(m.constData()))
return;
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", m.constData());
if (m_agent)
snprintf(cst_agent_config_mut(m_agent)->mode,
sizeof(cst_agent_config_mut(m_agent)->mode), "%s",
m.constData());
emit modeChanged();
}
void AgentCore::setApproval(int approval) {
m_cfg.approval = approval;
if (m_agent)
cst_agent_config_mut(m_agent)->approval = approval;
emit approvalChanged();
}
void AgentCore::setPlanMode(bool on) {
m_cfg.plan_mode = on ? 1 : 0;
if (m_agent)
cst_agent_config_mut(m_agent)->plan_mode = m_cfg.plan_mode;
emit planModeChanged();
}
/* --- sessions ------------------------------------------------------------ */
namespace {
QString utf8(const char *s) { return QString::fromUtf8(s ? s : ""); }
/* A transcript row in the shape Main.qml appends live (see addRow()). */
QVariantMap sessionRow(const QString &kind, const QString &role) {
QVariantMap r;
r["kind"] = kind;
r["role"] = role;
r["text"] = QString();
r["html"] = QString();
r["complete"] = false;
r["name"] = QString();
r["callId"] = QString();
r["state"] = QString();
r["detail"] = QString();
r["output"] = QString();
r["exitCode"] = 0;
return r;
}
/* The open tool row this result belongs to, -1 when none. Rows are paired by
* call id first: the same tool may run twice in a turn, and pairing by name
* would hang the first card on "running" forever. Only a call without an id
* (an old session written by a text protocol) falls back to the name. */
int findOpenTool(const QVariantList &rows, const QString &callId,
const QString &name) {
for (int i = rows.size() - 1; i >= 0; i--) {
const QVariantMap r = rows.at(i).toMap();
if (r.value(QStringLiteral("kind")).toString() != QLatin1String("tool"))
continue;
if (r.value(QStringLiteral("complete")).toBool())
continue;
const QString id = r.value(QStringLiteral("callId")).toString();
if (!callId.isEmpty()) {
if (id == callId)
return i;
continue; /* another call: its card may be further back */
}
if (!id.isEmpty())
continue;
if (r.value(QStringLiteral("name")).toString() == name)
return i;
return -1;
}
return -1;
}
} // namespace
QString AgentCore::sessionId() const {
if (!m_agent)
return QString();
char id[128] = "";
cst_agent_session_id(m_agent, id, sizeof(id));
return utf8(id);
}
QVariantList AgentCore::sessions() const {
QVariantList out;
if (m_workspace.isEmpty())
return out;
const QByteArray ws = m_workspace.toUtf8();
const int kMax = 200;
char(*ids)[128] = new char[kMax][128];
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
const QString current = sessionId();
for (int i = 0; i < n; i++) {
cst_session_info_t in;
memset(&in, 0, sizeof(in));
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
continue;
const QString id = utf8(in.id[0] ? in.id : ids[i]);
QVariantMap m;
m["id"] = id;
m["title"] = utf8(in.title);
m["firstUser"] = utf8(in.first_user);
m["messages"] = in.messages;
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
m["current"] = (id == current);
out.append(m);
}
delete[] ids;
return out;
}
QVariantList AgentCore::history() const {
QVariantList rows;
cst_session_t *s = m_agent ? cst_agent_session(m_agent) : nullptr;
if (!s)
return rows;
cst_session_lock(s);
const int n = cst_session_count(s);
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
if (!m || m->role == CST_ROLE_SYSTEM)
continue; /* the prompt is the engine's, not the conversation */
if (m->role == CST_ROLE_TOOL || cst_message_is_tool_result(m)) {
/* Tool output: role=tool in the native protocol, a user message in the
* text ones. Both belong on the card of the call they answer. */
const QString name = utf8(m->tool_name);
const QString callId = utf8(m->tool_call_id);
const int at = findOpenTool(rows, callId, name);
if (at < 0) {
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
r["name"] = name;
r["callId"] = callId;
r["output"] = utf8(m->content);
r["state"] = "done";
r["complete"] = true;
rows.append(r);
} else {
QVariantMap r = rows.at(at).toMap();
r["output"] = utf8(m->content);
r["state"] = "done";
r["complete"] = true;
rows[at] = r;
}
continue;
}
if (cst_message_is_note(m)) {
QVariantMap r = sessionRow(QStringLiteral("note"), QStringLiteral("engine"));
r["text"] = utf8(m->content);
r["complete"] = true;
rows.append(r);
continue;
}
if (m->role == CST_ROLE_USER) {
QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("user"));
QString text = utf8(m->content);
if (m->nattach > 0) {
QStringList names;
for (int a = 0; a < m->nattach; a++)
names << utf8(m->attach[a].name);
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
}
r["text"] = text;
r["complete"] = true;
rows.append(r);
continue;
}
if (m->role != CST_ROLE_ASSISTANT)
continue;
if (m->reasoning && m->reasoning[0]) {
QVariantMap r = sessionRow(QStringLiteral("think"), QStringLiteral("agent"));
r["text"] = utf8(m->reasoning);
r["complete"] = true;
rows.append(r);
}
if (m->content && m->content[0]) {
QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("agent"));
r["text"] = utf8(m->content);
r["complete"] = true;
rows.append(r);
}
if (m->tool_calls_json) {
const QJsonArray calls =
QJsonDocument::fromJson(QByteArray(m->tool_calls_json)).array();
for (const QJsonValue &v : calls) {
const QJsonObject o = v.toObject();
const QJsonObject fn = o.value(QStringLiteral("function")).toObject();
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
r["name"] = fn.value(QStringLiteral("name")).toString();
r["callId"] = o.value(QStringLiteral("id")).toString();
r["detail"] = fn.value(QStringLiteral("arguments")).toString();
r["state"] = "unfinished"; /* until its result shows up below */
rows.append(r);
}
}
}
cst_session_unlock(s);
return rows;
}
void AgentCore::afterSessionChange() {
const QString todo = utf8(cst_agent_todo(m_agent));
emit sessionChanged();
emit sessionListChanged();
if (todo != m_todo) {
m_todo = todo;
emit todoChanged(todo);
}
}
bool AgentCore::newSession() {
if (!m_agent || m_running)
return false;
char id[128] = "";
if (cst_agent_new_session(m_agent, id, sizeof(id)) != 0)
return false;
afterSessionChange();
return true;
}
bool AgentCore::switchSession(const QString &id) {
if (!m_agent || m_running || id.isEmpty())
return false;
if (id == sessionId())
return true;
if (cst_agent_switch_session(m_agent, id.toUtf8().constData()) != 0)
return false;
afterSessionChange();
return true;
}
bool AgentCore::deleteSession(const QString &id) {
if (!m_agent || m_running || id.isEmpty() || m_workspace.isEmpty())
return false;
const bool wasOpen = (id == sessionId());
if (wasOpen) {
/* The engine must not sit on a transcript that is about to vanish: it
* moves to a fresh session first, exactly like the WebUI does. */
char fresh[128] = "";
if (cst_agent_new_session(m_agent, fresh, sizeof(fresh)) != 0)
return false;
}
const QByteArray ws = m_workspace.toUtf8();
const QByteArray sid = id.toUtf8();
if (cst_agent_store_delete(ws.constData(), sid.constData()) != 0)
return false;
if (wasOpen)
afterSessionChange();
else
emit sessionListChanged();
return true;
}
bool AgentCore::renameSession(const QString &id, const QString &title) {
if (id.isEmpty() || m_workspace.isEmpty())
return false;
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
t = title.toUtf8();
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
0)
return false;
emit sessionListChanged();
return true;
}
/* --- provider, model, key ------------------------------------------------ */
void AgentCore::syncConfig() {
if (m_agent)
m_cfg = *cst_agent_config(m_agent);
emit configChanged();
}
QVariantList AgentCore::providers() const {
QVariantList out;
cst_settings_t st;
memset(&st, 0, sizeof(st));
const bool have_settings = cst_settings_load(&st) == 0;
const int n = cst_provider_count();
for (int i = 0; i < n; i++) {
const cst_provider_t *p = cst_provider_at(i);
if (!p || !p->name)
continue;
char key[512] = "";
const char *from = "";
const bool has_key =
cst_creds_resolve(p->name, key, sizeof(key), &from) == 0;
memset(key, 0, sizeof(key)); /* never let it outlive the call */
const char *remembered =
have_settings ? cst_settings_model(&st, p->name) : nullptr;
QVariantMap m;
m["name"] = utf8(p->name);
m["base"] = utf8(cst_ps_base(&m_cfg, p->name));
m["model"] = utf8(remembered && remembered[0]
? remembered
: (p->model ? p->model : ""));
m["hasKey"] = has_key;
m["keyFrom"] = utf8(from);
m["keyEnv"] = utf8(p->key_env);
m["source"] = utf8(cst_provider_source(p));
m["custom"] = strcmp(cst_provider_source(p), "user") == 0;
m["current"] = (m_cfg.provider[0] && strcmp(p->name, m_cfg.provider) == 0);
out.append(m);
}
return out;
}
QStringList AgentCore::modelChoices(const QString &provider) const {
QStringList out;
if (provider.isEmpty())
return out;
const QByteArray name = provider.toUtf8();
const int kMax = 64;
char(*ids)[128] = new char[kMax][128];
const cst_provider_t *p = cst_provider_find(name.constData());
if (p) {
const int n = cst_provider_models(p, ids, kMax);
for (int i = 0; i < n && i < kMax; i++)
if (ids[i][0])
out << utf8(ids[i]);
}
int fresh = 0;
const int cached = cst_ps_cached(name.constData(), ids, kMax, &fresh);
for (int i = 0; i < cached && i < kMax; i++)
if (ids[i][0])
out << utf8(ids[i]);
delete[] ids;
cst_settings_t st;
memset(&st, 0, sizeof(st));
if (cst_settings_load(&st) == 0) {
const char *cur = cst_settings_model(&st, name.constData());
if (cur && cur[0])
out.prepend(utf8(cur));
}
out.removeDuplicates();
return out;
}
QString AgentCore::modelInfo(const QString &model) const {
if (model.isEmpty())
return QString();
cst_model_info_t mi;
memset(&mi, 0, sizeof(mi));
const QByteArray mdl = model.toUtf8();
cst_model_info(m_cfg.provider[0] ? m_cfg.provider : nullptr, mdl.constData(),
&mi);
char label[64] = "";
cst_model_info_label(&mi, label, sizeof(label));
return utf8(label);
}
QString AgentCore::useProvider(const QString &provider, const QString &model,
const QString &key) {
if (!m_agent)
return QStringLiteral("no engine");
if (m_running)
return QStringLiteral("a turn is running — stop it first");
if (provider.isEmpty())
return QStringLiteral("pick a provider");
const QByteArray name = provider.toUtf8(), k = key.toUtf8();
const QByteArray mdl = model.trimmed().toUtf8();
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
char err[512] = "";
if (strcmp(name.constData(), cfg->provider) != 0) {
const int rc = cst_ps_switch_provider(
cfg, name.constData(), k.isEmpty() ? nullptr : k.constData(), err,
sizeof(err));
if (rc < 0)
return rc == CST_PS_UNKNOWN
? QStringLiteral("no such provider: %1").arg(provider)
: rc == CST_PS_NEED_KEY
? QStringLiteral("no key for %1 yet: paste one here")
.arg(provider)
: utf8(err[0] ? err : "the provider was refused");
if (rc & CST_PS_UNVERIFIED)
emit statusChanged(QStringLiteral("%1: key saved, not verified (%2)")
.arg(provider, utf8(err)));
else if (rc & CST_PS_UNSAVED)
emit statusChanged(QStringLiteral("%1: the key was not saved (%2)")
.arg(provider, utf8(err)));
} else if (!k.isEmpty()) {
const int rc = cst_ps_set_key(cfg, k.constData(), err, sizeof(err));
if (rc < 0)
return utf8(err[0] ? err : "the key was rejected");
if (rc & CST_PS_UNVERIFIED)
emit statusChanged(QStringLiteral("key saved, not verified (%1)")
.arg(utf8(err)));
}
if (mdl[0] && strcmp(mdl.constData(), cfg->model) != 0) {
snprintf(cfg->model, sizeof(cfg->model), "%s", mdl.constData());
cst_settings_remember(cfg);
}
/* A base URL edited in the advanced form (a custom provider, or a preset we
* override) takes effect without a trip through a different provider. */
const cst_provider_t *entry = cst_provider_find(name.constData());
if (entry && entry->api_base && strcmp(entry->api_base, cfg->api_base) != 0)
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", entry->api_base);
/* And the generation parameters remembered for whichever provider we ended
* on (cst_agent_create() does the same for the one it starts with). */
cst_settings_t st;
if (cst_settings_load(&st) == 0) {
const cst_gen_params_t *g = cst_settings_params(&st, cfg->provider);
cfg->temperature = g ? g->temperature : 0;
cfg->top_p = g ? g->top_p : 0;
cfg->max_tokens = g ? g->max_tokens : 0;
}
syncConfig();
return QString();
}
QString AgentCore::addProvider(const QString &name, const QString &base,
const QString &keyEnv,
const QString &defaultModel,
const QString &modelsCsv) {
const QByteArray n = name.trimmed().toUtf8();
const QByteArray b = base.trimmed().toUtf8();
const QByteArray ke = keyEnv.trimmed().toUtf8();
const QByteArray dm = defaultModel.trimmed().toUtf8();
/* "a, b, c" -> the model list the engine will offer for it. */
QVector<QByteArray> list;
for (const QString &part : modelsCsv.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
const QByteArray t = part.trimmed().toUtf8();
if (!t.isEmpty() && list.size() < 32)
list.append(t);
}
QVector<const char *> ptrs;
for (const QByteArray &p : list)
ptrs.append(p.constData()); /* list is complete: no reallocation after */
char err[512] = "";
if (cst_models_user_set_provider(n.constData(), b.constData(),
ke.isEmpty() ? nullptr : ke.constData(),
dm.isEmpty() ? nullptr : dm.constData(),
ptrs.isEmpty() ? nullptr : ptrs.constData(),
ptrs.size(), err, sizeof(err)) != 0)
return utf8(err[0] ? err : "the provider was refused");
/* Editing the live provider's own entry: its new base URL is live at once. */
if (m_agent && m_cfg.provider[0] && strcmp(m_cfg.provider, n.constData()) == 0) {
const cst_provider_t *p = cst_provider_find(n.constData());
if (p && p->api_base && strcmp(p->api_base, m_cfg.api_base) != 0) {
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
syncConfig();
}
}
return QString();
}
int AgentCore::removeProvider(const QString &name) {
const QByteArray n = name.trimmed().toUtf8();
if (n.isEmpty())
return -1;
char err[512] = "";
const int rc = cst_models_user_drop_provider(n.constData(), err, sizeof(err));
if (rc < 0)
emit statusChanged(QStringLiteral("%1: %2")
.arg(name, utf8(err[0] ? err : "not removed")));
return rc;
}
QVariantMap AgentCore::genParams(const QString &provider) const {
QVariantMap m;
m["temperature"] = 0.0;
m["top_p"] = 0.0;
m["max_tokens"] = 0;
const QByteArray name = provider.trimmed().toUtf8();
cst_settings_t st;
if (cst_settings_load(&st) == 0) {
const cst_gen_params_t *g = cst_settings_params(&st, name.constData());
if (g) {
m["temperature"] = g->temperature;
m["top_p"] = g->top_p;
m["max_tokens"] = g->max_tokens;
}
}
/* The live provider's config is what actually goes out: show that, not what
* a file says about it. */
if (name.size() && m_cfg.provider[0] &&
strcmp(m_cfg.provider, name.constData()) == 0) {
m["temperature"] = m_cfg.temperature;
m["top_p"] = m_cfg.top_p;
m["max_tokens"] = m_cfg.max_tokens;
}
return m;
}
QString AgentCore::setGenParams(const QString &provider, double temperature,
double topP, int maxTokens) {
const QByteArray name = provider.trimmed().toUtf8();
if (name.isEmpty())
return QStringLiteral("pick a provider first");
cst_gen_params_t g;
memset(&g, 0, sizeof(g));
snprintf(g.provider, sizeof(g.provider), "%s", name.constData());
g.temperature = temperature > 0 ? temperature : 0;
g.top_p = topP > 0 ? topP : 0;
g.max_tokens = maxTokens > 0 ? maxTokens : 0;
if (cst_settings_remember_params(name.constData(), &g) != 0)
return QStringLiteral("the parameters were not saved");
if (m_agent && strcmp(m_cfg.provider, name.constData()) == 0) {
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
cfg->temperature = g.temperature;
cfg->top_p = g.top_p;
cfg->max_tokens = g.max_tokens;
syncConfig();
}
return QString();
}
QVariantMap AgentCore::usage() const {
QVariantMap m;
if (!m_agent)
return m;
cst_usage_t session, turn;
memset(&session, 0, sizeof(session));
memset(&turn, 0, sizeof(turn));
cst_agent_usage(m_agent, &session, &turn);
m["requests"] = session.requests;
m["prompt"] = static_cast<qlonglong>(session.prompt);
m["out"] = static_cast<qlonglong>(session.out);
m["cached"] = static_cast<qlonglong>(session.cached);
m["cost"] = session.cost;
m["currency"] = utf8(session.currency);
m["priced"] = session.priced != 0;
m["turnPrompt"] = static_cast<qlonglong>(turn.prompt);
m["turnOut"] = static_cast<qlonglong>(turn.out);
return m;
}
/* --- attachments ---------------------------------------------------------- */
QStringList AgentCore::pendingAttachments() const {
QStringList out;
if (!m_agent)
return out;
const int n = cst_agent_pending_count(m_agent);
for (int i = 0; i < n; i++) {
char line[256] = "";
if (cst_agent_pending_describe(m_agent, i, line, sizeof(line)) != 0)
continue;
/* The engine describes what the model gets ("png 12.0 KB"); the file it
* came from is the window's own business, so the name goes in front. */
const QString desc = utf8(line);
out << (i < m_attachNames.size()
? QStringLiteral("%1 — %2").arg(m_attachNames.at(i), desc)
: desc);
}
return out;
}
QString AgentCore::attachFile(const QString &path) {
if (!m_agent)
return QStringLiteral("no engine");
if (path.isEmpty())
return QStringLiteral("no file");
char err[256] = "";
if (cst_agent_attach_file(m_agent, path.toUtf8().constData(), err,
sizeof(err)) != 0)
return utf8(err[0] ? err : "that file cannot be attached");
m_attachNames << QFileInfo(path).fileName();
emit pendingChanged();
return QString();
}
QString AgentCore::attachClipboard() {
if (!m_agent)
return QStringLiteral("no engine");
QGuiApplication *gui =
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
if (!gui || !gui->clipboard())
return QStringLiteral("no clipboard here");
/* An image is what this is for: text belongs to the editor's own paste. */
const QImage img = gui->clipboard()->image();
if (img.isNull())
return QStringLiteral("no image on the clipboard");
QByteArray png;
QBuffer buf(&png);
buf.open(QIODevice::WriteOnly);
if (!img.save(&buf, "PNG"))
return QStringLiteral("cannot encode the clipboard image");
char err[256] = "";
if (cst_agent_attach_bytes(m_agent, "clipboard.png", png.constData(),
static_cast<size_t>(png.size()), err,
sizeof(err)) != 0)
return utf8(err[0] ? err : "the clipboard image was refused");
m_attachNames << QStringLiteral("clipboard.png");
emit pendingChanged();
return QString();
}
void AgentCore::clearPending() {
if (!m_agent)
return;
cst_agent_pending_clear(m_agent);
m_attachNames.clear();
emit pendingChanged();
}
bool AgentCore::clipboardHasImage() const {
QGuiApplication *gui =
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
if (!gui || !gui->clipboard())
return false;
return !gui->clipboard()->image().isNull();
}
int AgentCore::seesImages() const {
return m_agent ? cst_agent_sees_images(m_agent) : 0;
}
/* --- tools the front end provides ---------------------------------------- */
bool AgentCore::registerTool(const cst_tool_def_t *def) {
if (!def || cst_tool_register(def) != 0)
return false;
return true;
}
void AgentCore::unregisterOwner(const QString &owner) {
const QByteArray o = owner.toUtf8();
if (cst_tool_unregister_owner(o.constData()) > 0)
cst_tool_meta_reload();
}
/* --- hook trampolines ---------------------------------------------------- */
void AgentCore::hookStatus(const char *msg, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString m = QString::fromUtf8(msg ? msg : "");
self->post([self, m] { emit self->statusChanged(m); });
}
void AgentCore::hookAssistant(const char *text, size_t len, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString t = QString::fromUtf8(QByteArray(text, static_cast<int>(len)));
self->post([self, t] { emit self->assistantDelta(t); });
}
void AgentCore::hookThink(const char *text, size_t len, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString t = QString::fromUtf8(QByteArray(text, static_cast<int>(len)));
self->post([self, t] { emit self->thinkDelta(t); });
}
void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString p = QString::fromUtf8(phase ? phase : "");
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
const QString n = QString::fromUtf8(call ? call->name : "");
const QString d = QString::fromUtf8(detail ? detail : "");
self->post([self, p, id, n, d] { emit self->toolEvent(p, id, n, d); });
}
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
const QString n = QString::fromUtf8(call ? call->name : "");
QString o = QString::fromUtf8(QByteArray(out, static_cast<int>(len)));
if (o.size() > 4000)
o = o.left(4000) + QStringLiteral("\n… (truncated for the UI)");
self->post(
[self, id, n, exit_code, o] { emit self->toolResult(id, n, exit_code, o); });
}
void AgentCore::hookTodo(const char *text, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString t = QString::fromUtf8(text ? text : "");
/* Runs on the worker thread: touch m_todo on the GUI thread only. */
self->post([self, t] {
self->m_todo = t;
emit self->todoChanged(t);
});
}