#include "AgentCore.h" #include #include #include #include #include #include #include #include #include #include #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()); /* 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 --------------------------------------------------------------- */ 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(sender()); post([this, rc, err] { setRunning(false); 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(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(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["complete"] = false; r["name"] = QString(); r["state"] = QString(); r["detail"] = QString(); r["output"] = QString(); r["exitCode"] = 0; return r; } /* The last tool row with this name that is still open, -1 when none: the * pairing findTool() does for the live stream, so a replayed result lands on * the card of its own call. An unmatched call keeps state "unfinished": an * interrupted turn should not show a spinner that will never stop. */ int findOpenTool(const QVariantList &rows, 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()) return -1; if (r.value(QStringLiteral("name")).toString() == name) return i; } 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(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 int at = findOpenTool(rows, name); if (at < 0) { QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool")); r["name"] = name; 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 fn = v.toObject().value(QStringLiteral("function")).toObject(); QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool")); r["name"] = fn.value(QStringLiteral("name")).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; } /* --- 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 n = QString::fromUtf8(call ? call->name : ""); const QString d = QString::fromUtf8(detail ? detail : ""); self->post([self, p, n, d] { emit self->toolEvent(p, 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(ud); 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, n, exit_code, o] { emit self->toolResult(n, exit_code, o); }); } 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); }); }