#include "AgentCore.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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(in.size())); if (!html) return text.toHtmlEscaped().replace(QLatin1Char('\n'), QStringLiteral("
")); 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 { /* How long the worker waits for a person. Long enough to read a file diff and * decide, short enough that a missed dialog (or a screen that went dark) does * not leave the turn waiting for good. */ constexpr int kConsentTimeoutMs = 2 * 60 * 1000; constexpr int kQuestionTimeoutMs = 5 * 60 * 1000; /* 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); } /* Qt suspends the application when the phone puts it away, and a desktop * window can be gone too (--uitest runs headless). Either way a consent * dialog would be invisible. */ bool AgentCore::backgrounded() const { QGuiApplication *gui = qobject_cast(QCoreApplication::instance()); if (!gui) return false; if (gui->applicationState() == Qt::ApplicationSuspended) return true; return gui->allWindows().isEmpty() && gui->topLevelWindows().isEmpty(); } void AgentCore::finishTurn(int rc, const QString &err) { Turn *t = static_cast(sender()); if (t) { /* run() is the caller: waiting for the thread here would be waiting for * this very call to return. Let the event loop reap it instead. */ connect(t, &QThread::finished, t, &QObject::deleteLater); } post([this, rc, err] { m_turn = nullptr; 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); }); } 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(ud); if (!self || self->m_closing) return CST_PERM_DENY; if (self->m_autoApprove) return CST_PERM_ONCE; const QString t = QString::fromUtf8(tool ? tool : ""); /* The turn survives the app leaving the screen (v0.9.4), so a question * asked there would block the worker on a dialog nobody can see -- which is * what "the app hangs while the agent works" looks like. In the background * the answer is no, with a line the person finds in the transcript. */ if (self->backgrounded()) { self->post([self, t] { emit self->permissionExpired(t); emit self->statusChanged( QStringLiteral("%1: refused — the app was in the background").arg(t)); }); return CST_PERM_DENY; } QMutexLocker lock(&self->m_askMu); self->m_permAnswer = CST_PERM_DENY; self->m_permPending = true; 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); }); /* And even in the foreground nobody may hang forever: a dialog can be * missed, a screen can go dark. Two minutes, then the answer is no. */ bool expired = false; while (self->m_permPending && !self->m_closing) { if (!self->m_askCv.wait(&self->m_askMu, kConsentTimeoutMs)) { expired = true; break; } } self->m_permPending = false; if (expired) { self->post([self, t] { emit self->permissionExpired(t); emit self->statusChanged( QStringLiteral("%1: no answer for two minutes — refused").arg(t)); }); return CST_PERM_DENY; } 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(ud); if (!self || self->m_closing) return -1; if (self->m_autoApprove) return -1; if (self->backgrounded()) { self->post([self] { emit self->questionExpired(); emit self->statusChanged( QStringLiteral("the question was dropped — the app was in the " "background")); }); 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); }); bool expired = false; while (self->m_questionPending && !self->m_closing) { if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) { expired = true; break; } } if (self->m_closing) return -1; self->m_questionPending = false; if (expired) { self->post([self] { emit self->questionExpired(); emit self->statusChanged( QStringLiteral("no answer for five minutes — the question was " "dropped")); }); 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: * * ||||||x * * 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()) { m_sessionCache.clear(); 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(); QHash fresh; fresh.reserve(n); for (int i = 0; i < n; i++) { const QString id = utf8(ids[i]); /* Filling one row walks that session's whole transcript, and the sidebar * asks for the list on every store change: the transcript's own stat is * what tells "nothing happened to this one" from "it grew", so an * unchanged session costs a stat instead of a read. */ char path[4096] = ""; const bool have_path = cst_agent_store_path_for_id( ws.constData(), ids[i], path, sizeof(path)) == 0; const QFileInfo fi(have_path ? QString::fromUtf8(path) : QString()); const qint64 mtime = fi.lastModified().toSecsSinceEpoch(); const qint64 size = fi.size(); SessionCache entry; const auto cached = m_sessionCache.constFind(id); if (cached != m_sessionCache.constEnd() && cached->mtime == mtime && cached->size == size) { entry = *cached; } else { cst_session_info_t in; memset(&in, 0, sizeof(in)); if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0) continue; entry.mtime = mtime; entry.size = size; QVariantMap m; m["id"] = id; m["title"] = utf8(in.title); m["firstUser"] = utf8(in.first_user); m["messages"] = in.messages; m["mtime"] = static_cast(in.mtime) * 1000; /* ms, for JS Date */ entry.row = m; } QVariantMap m = entry.row; m["current"] = (id == current); out.append(m); fresh.insert(id, entry); } delete[] ids; m_sessionCache = fresh; /* and the ids that are gone are forgotten */ 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; /* A title is not in the transcript, so the cached rows would not notice the * rename: drop them and let the next list read them again. */ m_sessionCache.clear(); 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 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 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(session.prompt); m["out"] = static_cast(session.out); m["cached"] = static_cast(session.cached); m["cost"] = session.cost; m["currency"] = utf8(session.currency); m["priced"] = session.priced != 0; m["turnPrompt"] = static_cast(turn.prompt); m["turnOut"] = static_cast(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(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(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(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(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(ud); const QString t = QString::fromUtf8(QByteArray(text, static_cast(len))); self->post([self, t] { emit self->assistantDelta(t); }); } void AgentCore::hookThink(const char *text, size_t len, void *ud) { AgentCore *self = static_cast(ud); const QString t = QString::fromUtf8(QByteArray(text, static_cast(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(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); /* The header says what the turn is doing right now ("shell: make -j8"), * not just that something is happening: a long turn is otherwise * indistinguishable from a stuck one. */ if (p == QLatin1String("done")) { emit self->statusChanged(QString()); return; } QString what = d; if (what.size() > 80) what = what.left(79) + QStringLiteral("…"); emit self->statusChanged(what.isEmpty() ? n : QStringLiteral("%1: %2").arg(n, what)); }); } void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out, size_t len, int exit_code, void *ud) { AgentCore *self = static_cast(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(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(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(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); }); }