Files
CST-agent/app/src/core/AgentCore.cpp
T
WHC 8694afca94 v0.8.13: tool cards paired by call id, markdown rendered while it streams
Two things the window got wrong once a real turn ran through it, plus the
switch that was invisible on a dark theme.

Tool cards (app/src/core/AgentCore.*, app/qml/Main.qml, ToolCard.qml):
- A turn that called the same tool twice left the first card on "running"
  forever and gave its result to the second card: both were paired by name.
  cst_agent_hooks_t hands the call (ctl_tool_call_t) to every callback, so the
  id travels with the signals now (toolEvent(phase, id, name, detail),
  toolResult(id, name, exitCode, output)) and findTool() matches on it, in the
  same way AgentCore::history() fills it from tool_call_id / tool_calls_json
  for a replayed session. Only a call without an id -- an old text-protocol
  session -- still falls back to the name.
- Nothing may keep spinning after a turn: closeAllRows() runs when the turn
  ends and settles every open row; a call that never came back shows "no
  result" (state "unfinished") and no longer claims an exit code.

Streaming text (app/qml/Main.qml, ChatView.qml):
- The answer was plain text while it streamed and turned into markdown only
  when the turn ended. A 120 ms timer now re-renders the open answer with the
  engine's own renderer (AgentCore::renderMarkdown -> cst_web_md_html), so a
  list, a table or a code fence fills in formatted as it arrives. A row keeps
  its own "html" field; a replayed session renders once, where its rows are
  built.

QML details:
- the Plan mode Switch got a drawn indicator: the Basic style's pill is a dark
  pill with a dark knob on this theme, which made the control look disabled.
- Every engine signal handler is a plain window function now
  (engineToolEvent, engineToolResult, engineTurnFinished, engineAssistant,
  ...), which is also what lets --uitest drive the exact path; rowJson(i)
  returns a transcript row as JSON for it (an object would not survive
  QMetaObject::invokeMethod).

Tests: 6 more checks -- a call opens a card, two calls of one tool are two
cards, the result lands on the card of its own call, the other card is
untouched, the turn end settles an open call, a delta opens a streaming answer
and markdown is rendered while it still streams. 52/52 pass, --selftest,
ctest cst-app-selftest.

Version 0.8.13.
2026-10-11 12:44:20 +08:00

836 lines
27 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["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);
}
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);
});
}