Files
Hermit/app/src/core/AgentCore.cpp
T
WHC 580d533656
ci / tcc-check (push) Successful in 1m9s
ci / linux (aarch64, 0) (push) Failing after 1h0m2s
ci / linux (x86, 1) (push) Failing after 1h0m2s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 10m4s
v0.9.6: documents, downloads, PDFs and the phone's own files
Four tools and a document writer, plus the storage half of the phone: until
now the picker's answer (a content:// URI) reached the engine as a path, and
it said "cannot read" -- the app had no way to hand a phone's file over.

- md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one
  self-contained HTML document out -- doctype, utf-8, viewport, escaped
  title, a small stylesheet; no script, no external request. It is written
  into the workspace and delivered as a file card (for_user, so it never
  enters a request), the way deliver_file does. The default name comes from
  the source file's stem or, for pasted markdown, from the title with what a
  path cannot carry turned into '-'. It has a preview, so an approval prompt
  says "render x -> y.html" before anything is written.
- download (src/tools/web.c): an http(s) URL into a file in the workspace,
  streamed with a 128 MB ceiling; a redirect chain, the same CA handling and
  the same refusals as web_fetch, but the bytes stay on disk (a half file is
  removed on any failure, including HTTP >= 400). The name comes from the URL
  when the caller names only a directory.
- read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF,
  which read_file refuses because the bytes are binary -- and a PDF is
  exactly what arrives from a phone. Same extractor an attachment uses, its
  own 4 MiB input ceiling, 256 KiB of text by default with a note when cut.
- session_search (new, src/tools/session_tools.c): every stored session --
  this one and all the ones before it -- searched for a substring, reporting
  the session's id, message count and title, then the matching lines with
  their number and role. Case-insensitive by default, capped at 20 matches.
- device_info (same file, perm_name "device"): version, system/arch/CPU
  count, workspace, sandbox, model and permissions, config/temp/program
  directories, pid -- so the model stops guessing where it runs.
- Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files):
  the document is a whole page, a script tag and a javascript: link do not
  survive, a hostile title stays text and cannot write outside the workspace,
  the preview names the file, read_pdf/download refuse what they must (no
  network in a test), session_search finds a fixture session and says so when
  nothing matches, device_info names the workspace. README's tool table grew
  five rows.

- Android storage: the picker (SAF) answers with a content:// URI, and the
  engine opens files by path, so the app now copies what it points at into
  its own directory first -- app/android/src/xyz/whcself/cstagent/
  AttachFile.java, called through a new app::materializeAttachment()
  (ToolProvider.h: one API for every platform; a desktop strips file:// and
  refuses content:// with a reason). The copy needs no permission at all: the
  grant travels with the URI.
- "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what
  lets the engine read a path in shared storage that a task names rather than
  picks. Android has no runtime dialog for it, so main.cpp opens the one
  Settings screen once (AttachFile.java's requestAllFilesAccess, with the
  all-apps screen as a fallback) and never insists: without it everything
  works, one picked file at a time. On the NX789J the screen came up, the
  app-op reads back "allow", and a relaunch no longer asks.
- Composer.qml hands a content:// URI through untouched (it is not a path,
  and decoding it would corrupt it); AgentCore::attachFile() turns whatever
  the picker gave into a path first.

Desktop: build clean, make check 36/36 (the new test included, also under
ASan), --uitest all green, --selftest and cst-app-selftest OK.
The APK builds and installs (86 MB, release-signed) with the new Java class
in its dex.

Version 0.9.6.
2026-10-11 18:33:41 +08:00

1175 lines
41 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 "ToolProvider.h" /* openLocalFile / shareLocalFile */
#include "cst_agent.h"
#include "cst_attach.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_tool_images = hookToolImages;
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);
}
}
/* The window is started without --api-key, and the engine's config never
* resolves credentials on its own: without this the key the user saved for
* the provider would be in the file but not in the turn, and every start
* would ask for it again. Same order as the CLI (main.c): a key passed to
* open() > the saved one > the provider's env var > CST_API_KEY. */
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
char key[512] = "";
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
const char *env = getenv("CST_API_KEY");
if (env && env[0])
snprintf(key, sizeof(key), "%s", env);
}
if (key[0])
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s", key);
memset(key, 0, sizeof(key)); /* never let it outlive the call */
}
/* 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 --------------------------------------------------------------- */
namespace {
/* The first line of a prompt, cut short: the line the phone's "working"
* notification carries while the turn runs (app::holdForeground). */
QString firstLine(const QString &s) {
const int nl = s.indexOf(QLatin1Char('\n'));
QString line = (nl < 0 ? s : s.left(nl)).trimmed();
if (line.size() > 120)
line = line.left(119) + QStringLiteral("…");
return line;
}
} // namespace
void AgentCore::send(const QString &prompt) {
if (!m_agent || m_running || prompt.trimmed().isEmpty())
return;
/* The prompt takes the pending images with it: the view shows them on the
* message the composer just appended, before the turn can say anything. */
const QString sending = pendingImages();
setRunning(true);
/* A turn can take minutes: hold the process while it runs, or a phone
* freezes the app the moment it leaves the screen. "" on a desktop and
* whenever the hold is in place. */
const QString held = app::holdForeground(firstLine(prompt));
if (!held.isEmpty())
emit statusChanged(held);
if (!sending.isEmpty())
emit promptImages(sending);
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);
app::releaseForeground(); /* the turn is over: let the phone sleep again */
/* 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;
r["images"] = QString();
return r;
}
/* One field of one picture line: names come from the user and from tool
* output, so a separator may not split a line in two. */
QString field(const char *s) {
QString out = utf8(s);
out.replace(QLatin1Char('|'), QLatin1Char('/'));
out.replace(QLatin1Char('\n'), QLatin1Char(' '));
return out;
}
/* The files of one message or tool result, one line each:
*
* <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
*
* kind is "img" for a picture (a thumbnail the view can show) or "file" for a
* file the agent delivered (a card with open and share). A list role is not an
* option: a ListModel keeps what fits in a QVariant of a simple type -- a list
* reads back as undefined there, and setProperty drops the role -- so the rows
* carry this text and ImageStrip.qml decodes it. A document the user attached
* (PDF, text) is named in the message text and stays out of it, and so does
* anything older sessions wrote. */
QString filesText(const cst_attachment_t *list, int n) {
QStringList lines;
for (int i = 0; list && i < n; i++) {
const cst_attachment_t *a = &list[i];
const bool img = cst_attach_is_image(a) != 0;
if (!img && !a->for_user)
continue;
QString line = QStringLiteral("%1|%2|%3|%4|%5|%6|%7x%8");
line = line.arg(img ? QStringLiteral("img") : QStringLiteral("file"));
line = line.arg(field(a->rel));
line = line.arg(field(a->name));
line = line.arg(field(a->origin));
line = line.arg(field(a->mime));
line = line.arg(a->bytes);
line = line.arg(a->width);
line = line.arg(a->height);
lines << line;
}
return lines.join(QLatin1Char('\n'));
}
/* A stored file of this session, named the way a file card names it: its full
* path, or empty when the name is not one this session's store owns. A card
* hands back a name it got from the engine (filesText), so a name no message
* carries is not a file of this session at all -- never open it. `title`, when
* asked for, gets the display name the person knows the file by. */
QString artifactPathOf(cst_agent_t *agent, const QString &rel, QString *title) {
if (title)
title->clear();
if (!agent)
return QString();
const QByteArray r = rel.trimmed().toUtf8();
if (r.isEmpty() || !cst_attach_rel_ok(r.constData()))
return QString();
cst_session_t *s = cst_agent_session(agent);
if (!s)
return QString();
bool mine = false;
cst_session_lock(s);
const int n = cst_session_count(s);
for (int i = 0; !mine && i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
for (int a = 0; m && a < m->nattach; a++) {
if (strcmp(m->attach[a].rel, r.constData()) != 0)
continue;
mine = true;
if (title)
*title = utf8(m->attach[a].name);
break;
}
}
cst_session_unlock(s);
if (!mine)
return QString();
cst_attachment_t a;
memset(&a, 0, sizeof(a));
snprintf(a.rel, sizeof(a.rel), "%s", r.constData());
const char *session = cst_agent_session_path(agent);
char full[4096] = "";
if (!session || cst_attach_full_path(session, &a, full, sizeof(full)) != 0)
return QString();
return utf8(full);
}
/* 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);
}
/* Where the session keeps its images: what image://cstattach/ serves, and
* what the view asks for when the session changes. */
QString AgentCore::attachmentDir() const {
if (!m_agent)
return QString();
const char *sess = cst_agent_session_path(m_agent);
if (!sess || !sess[0])
return QString();
char dir[4096] = "";
if (cst_attach_dir(sess, dir, sizeof(dir)) != 0)
return QString();
return utf8(dir);
}
/* A file card's "open": the file goes to whatever the system uses for its
* type (Android ACTION_VIEW through the FileProvider, a desktop's own
* application). "" when it went through. */
QString AgentCore::openArtifact(const QString &rel) {
QString title;
const QString path = artifactPathOf(m_agent, rel, &title);
if (path.isEmpty())
return QStringLiteral("no such file in this session: %1").arg(rel);
QString status;
const QString err = app::openLocalFile(path, title, &status);
if (!err.isEmpty())
return err;
if (!status.isEmpty())
emit statusChanged(status);
return QString();
}
/* The same through the share sheet (a phone) or the clipboard (a desktop). */
QString AgentCore::shareArtifact(const QString &rel) {
QString title;
const QString path = artifactPathOf(m_agent, rel, &title);
if (path.isEmpty())
return QStringLiteral("no such file in this session: %1").arg(rel);
QString status;
const QString err = app::shareLocalFile(path, title, &status);
if (!err.isEmpty())
return err;
if (!status.isEmpty())
emit statusChanged(status);
return QString();
}
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["images"] = filesText(m->attach, m->nattach);
r["state"] = "done";
r["complete"] = true;
rows.append(r);
} else {
QVariantMap r = rows.at(at).toMap();
r["output"] = utf8(m->content);
r["images"] = filesText(m->attach, m->nattach);
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);
/* A document keeps its name in the text; a picture is shown instead
* (the images below), so only the rest is worth naming. */
QStringList names;
for (int a = 0; a < m->nattach; a++)
if (!cst_attach_is_image(&m->attach[a]))
names << utf8(m->attach[a].name);
if (!names.isEmpty())
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
r["text"] = text;
r["images"] = filesText(m->attach, m->nattach);
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()) {
/* A click on the row of the session that is already open: nothing to
* switch, but the window still wants the transcript (the row is not a
* dead spot), and sessionChanged is what draws it. */
emit sessionChanged();
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 ---------------------------------------------------------- */
/* What the prompt about to be sent carries, ready for the view. The stored
* name is the engine's (the file already lives next to the session), the
* display name is the one the window kept when it copied the file in. */
QString AgentCore::pendingImages() const {
QStringList lines;
if (!m_agent)
return QString();
const int n = cst_agent_pending_count(m_agent);
for (int i = 0; i < n; i++) {
if (!cst_agent_pending_is_image(m_agent, i))
continue;
const char *rel = cst_agent_pending_rel(m_agent, i);
if (!rel || !rel[0])
continue;
const QString name =
i < m_attachNames.size() ? m_attachNames.at(i) : utf8(rel);
/* The size is not known before the engine stores the file; the strip
* falls back to a picture's usual shape until it is. */
lines << QStringLiteral("%1|%2|user|0x0")
.arg(field(rel), field(name.toUtf8().constData()));
}
return lines.join(QLatin1Char('\n'));
}
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");
/* What a picker hands over is not always a path: on a phone it is a SAF
* content:// URI, and the app copies that into its own directory first (the
* engine opens files by path). A desktop passes the path through. */
QString err2;
const QString local = app::materializeAttachment(path, &err2);
if (local.isEmpty())
return err2.isEmpty() ? QStringLiteral("that file cannot be attached")
: err2;
char err[256] = "";
if (cst_agent_attach_file(m_agent, local.toUtf8().constData(), err,
sizeof(err)) != 0)
return utf8(err[0] ? err : "that file cannot be attached");
m_attachNames << QFileInfo(local).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::hookToolImages(const ctl_tool_call_t *call,
const cst_attachment_t *list, int n, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString images = filesText(list, n);
if (images.isEmpty())
return; /* a call that made no picture needs no signal */
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
self->post([self, id, images] { emit self->toolImages(id, images); });
}
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);
});
}