Files
CST-agent/app/src/core/AgentCore.h
T
WHC 042d7f6999 v0.9.7: a turn that cannot hang, a session list that reads nothing twice, and PDFs
- consent: on_permission / on_ask_user wait at most two / five minutes, and in
  the background (a phone off screen, a window that is gone) the answer is
  refused at once with a reason -- a dialog nobody can see blocked the worker
  for good, which is what "the app hangs while the agent works" looked like.
- finishTurn() no longer joins the thread it is called from; the event loop
  reaps the Turn instead.
- AndroidTools: the hop to the GUI thread pings first and gives up after 3 s
  instead of waiting forever on a busy GUI thread.
- the session list stopped reading every transcript: cst_agent_store_info()
  counts newlines in 64 KiB blocks (first_user only within the first 256 KiB),
  and AgentCore::sessions() reuses a row while (mtime, size) is unchanged.
- session_search gets a budget (max_bytes 32 MB, max_seconds 5 s), skips lines
  over 64 KiB, and says when the budget stopped the walk.
- the header shows the phase ("shell: make -j8"), so a long turn no longer
  looks like a stuck one.
- md_to_pdf: the front end's own Qt tool (owner app:qt, every platform):
  markdown or a workspace .md/.html becomes an A4 PDF with 15 mm margins,
  paginated by hand, written into the workspace and delivered as a file card.
  No PrintSupport, so the phone gets it too.
- --uitest runs md_to_pdf through the registry with Chinese text and checks the
  bytes are a PDF; app/CMakeLists.txt no longer drops src/web_md.c from the
  standalone engine build, which the window needs to render markdown.

Version 0.9.7.
2026-10-11 19:26:36 +08:00

286 lines
14 KiB
C++

/* AgentCore: the Qt wrapper around one cst_agent_t.
*
* The engine runs turns on a worker thread; every cst_agent_hooks_t callback
* is turned into a Qt signal, and the two hooks that need an answer
* (on_permission, on_ask_user) block the worker until the GUI replies. No
* HTTP, no socket: the GUI owns the engine in its own process. */
#ifndef CST_APP_AGENTCORE_H
#define CST_APP_AGENTCORE_H
#include <QByteArray>
#include <QHash>
#include <QMutex>
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include <QWaitCondition>
#include "cst_agent.h"
#include "cst_tools.h"
class QThread;
class AgentCore : public QObject {
Q_OBJECT
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
Q_PROPERTY(QString model READ model NOTIFY configChanged)
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
public:
explicit AgentCore(QObject *parent = nullptr);
~AgentCore() override;
/* Start the engine on a workspace. provider/model/apiKey may be empty to
* fall back to the saved credentials and the provider's default model. */
bool open(const QString &workspace, const QString &provider,
const QString &model, const QString &apiKey, QString *err);
void close();
bool running() const { return m_running; }
QString workspace() const { return m_workspace; }
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
QString model() const { return QString::fromUtf8(m_cfg.model); }
int approval() const { return m_cfg.approval; }
bool planMode() const { return m_cfg.plan_mode != 0; }
QString todo() const { return m_todo; }
QString sessionId() const;
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
* message looks the same here as it does in the WebUI. */
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
/* Stored sessions, newest first; one map each: id, title (a rename, else
* empty), firstUser, messages, mtime (ms since the epoch), current. */
Q_INVOKABLE QVariantList sessions() const;
/* The open session as transcript rows, in exactly the shape the live
* signals build (kind, role, text, name, state, detail, output, exitCode,
* complete, images), so a reopened session renders like the turn that wrote
* it. */
Q_INVOKABLE QVariantList history() const;
/* None of these run during a turn; all return false when one does. */
Q_INVOKABLE bool newSession();
Q_INVOKABLE bool switchSession(const QString &id);
Q_INVOKABLE bool deleteSession(const QString &id);
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
/* --- provider, model, key (see cst_provider_switch.h) ------------------- */
/* The presets: name, base, model (remembered, else the preset's), hasKey
* and keyFrom ("saved", else the environment variable), keyEnv, source and
* current, custom (the user's own provider, kept in the engine's user
* model file). Never a key: the engine keeps those out of the UI's reach. */
Q_INVOKABLE QVariantList providers() const;
/* A provider the engine's presets do not know: it goes into the user model
* file (<config>/cst-agent/models.json), which is where the registry keeps
* them, and the registry is reloaded, so the next providers() call lists
* it. `modelsCsv` is a comma-separated model list (may be empty).
* "" when it worked, else the reason. */
Q_INVOKABLE QString addProvider(const QString &name, const QString &base,
const QString &keyEnv,
const QString &defaultModel,
const QString &modelsCsv);
/* Drop a provider that came from that file (1 = it was there, 0 = it was
* not, -1 = with a reason). A preset of the same name stays. */
Q_INVOKABLE int removeProvider(const QString &name);
/* The generation parameters remembered for a provider (0 = not set, the
* field is not sent at all). */
Q_INVOKABLE QVariantMap genParams(const QString &provider) const;
/* Remember temperature / top-p / max tokens (0 = unset) for a provider and
* apply them to the engine when that provider is the live one.
* "" when it worked, else the reason. */
Q_INVOKABLE QString setGenParams(const QString &provider, double temperature,
double topP, int maxTokens);
/* Model candidates for a provider: its own list, what was fetched into the
* cache, and the remembered pick. No network. */
Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
/* "1M · vision" for the model picker, "" when nothing is known. */
Q_INVOKABLE QString modelInfo(const QString &model) const;
/* Switch provider, model and key in one call. The engine's own config moves
* (its session does not), the choice is remembered, and an empty return
* means it worked; anything else is a reason to show. A key is verified
* against the provider's /models when one is given. */
Q_INVOKABLE QString useProvider(const QString &provider, const QString &model,
const QString &key);
/* Tokens of this session and of the last turn, plus the cost when priced. */
Q_INVOKABLE QVariantMap usage() const;
/* --- attachments (see cst_attach.h) ------------------------------------- */
Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged)
/* What the next prompt will carry: "<name> (<mime>, 12 KB)" per line. */
QStringList pendingAttachments() const;
/* Copy a file next to the session and give it to the next prompt. "" when
* it worked, else why not. */
Q_INVOKABLE QString attachFile(const QString &path);
/* The same for an image on the clipboard (Ctrl+V). "" when it worked; a
* reason -- "no image on the clipboard" -- when the editor should have the
* keystroke instead. */
Q_INVOKABLE QString attachClipboard();
/* Whether Ctrl+V should become an attachment at all (an image is on the
* clipboard) or stay with the editor (text). */
Q_INVOKABLE bool clipboardHasImage() const;
Q_INVOKABLE void clearPending();
/* The images among the pending attachments, in the engine's order, in the
* same text history() and toolImages() carry (see imagesText()): one line
* per picture, "<stored name>|<name>|<origin>|<w>x<h>". Empty for a PDF or
* a text file, and for a picture whose stored name the engine did not
* report. */
Q_INVOKABLE QString pendingImages() const;
/* Where the current session keeps its stored images: the directory
* image://cstattach/ serves (SessionImages.cpp). "" until the engine is
* open. It follows the session. */
Q_INVOKABLE QString attachmentDir() const;
/* A file card's two buttons: the stored file goes to the system -- a phone
* opens it with ACTION_VIEW or hands it to the share sheet, a desktop gives
* it to its own application or to the clipboard. "" when it went through
* (the header gets the line from statusChanged), else what to say. The name
* is the stored one the card carries; anything else is refused. */
Q_INVOKABLE QString openArtifact(const QString &rel);
Q_INVOKABLE QString shareArtifact(const QString &rel);
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
Q_INVOKABLE int seesImages() const;
/* The engine, for the parts that need it (tool registration). */
cst_agent_t *agent() const { return m_agent; }
const cst_agent_config_t *config() const { return &m_cfg; }
/* Ask a question; emits the streaming signals, then turnFinished(). */
Q_INVOKABLE void send(const QString &prompt);
Q_INVOKABLE void cancel();
/* Consent: answers map to CST_PERM_*. */
Q_INVOKABLE void answerPermission(int choice);
Q_INVOKABLE void answerQuestion(const QString &answer);
Q_INVOKABLE void setMode(const QString &mode); /* coding | general */
Q_INVOKABLE void setApproval(int approval); /* cst_approval_t */
Q_INVOKABLE void setPlanMode(bool on);
/* Tools the front end implements itself. The engine copies the definition
* (strings included), so a stack value is fine; ud must outlive it.
* owner is what unregisterOwner() later matches. */
bool registerTool(const cst_tool_def_t *def);
void unregisterOwner(const QString &owner);
/* Re-probe platform tools after a service appeared or went away. */
void reloadToolMeta() { cst_tool_meta_reload(); }
/* Answer every consent question with "once" (the C smoke test, headless
* runs, a --yes flag). */
void setAutoApprove(bool on) { m_autoApprove = on; }
signals:
void configChanged();
void approvalChanged();
void planModeChanged();
void sessionChanged(); /* another session is open now */
void sessionListChanged(); /* the store gained, lost or renamed one */
void pendingChanged(); /* attachments for the next prompt changed */
void statusChanged(const QString &text);
void assistantDelta(const QString &text);
void thinkDelta(const QString &text);
/* A call is identified by its id: the same tool can run twice in one turn,
* and only the id tells the two cards apart. Text protocols carry ids too
* (cst_tool_call_from_native, the ctl parser), so this is never empty. */
void toolEvent(const QString &phase, const QString &id, const QString &name,
const QString &detail);
void toolResult(const QString &id, const QString &name, int exitCode,
const QString &output);
/* Images a tool produced (view_image, an MCP image), right after its
* result, as the text ImageStrip.qml decodes. */
void toolImages(const QString &callId, const QString &images);
/* The images the prompt that was just sent carries, in the same shape,
* fired before the turn's first delta. */
void promptImages(const QString &images);
void todoChanged(const QString &text);
void permissionRequested(const QString &tool, const QString &detail,
const QString &preview);
void questionAsked(const QString &question, const QString &options);
/* The worker asked and nobody answered in time (or the app was in the
* background, where the dialog cannot be seen): the turn went on with a
* refusal, and the window puts its dialog away again. */
void permissionExpired(const QString &tool);
void questionExpired();
void turnFinished(int rc, const QString &err);
void runningChanged();
void workspaceChanged();
void modeChanged();
private:
friend class Turn;
void finishTurn(int rc, const QString &err);
void setRunning(bool on);
/* Take the engine's config over m_cfg again (a switch changed it) and say
* so. */
void syncConfig();
/* Re-read what a session switch changed (id, todo) and say so. */
void afterSessionChange();
/* True when the app is not on screen: a phone in the background (Qt
* suspends it), or a window that is not there at all. A dialog opened now
* would be invisible, so consent is refused instead of waited for. */
bool backgrounded() const;
/* Hook trampolines (C, run on the worker thread). */
static void hookStatus(const char *msg, void *ud);
static void hookAssistant(const char *text, size_t len, void *ud);
static void hookThink(const char *text, size_t len, void *ud);
static void hookToolEvent(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud);
static void hookToolResult(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud);
static void hookToolImages(const ctl_tool_call_t *call,
const cst_attachment_t *list, int n, void *ud);
static void hookTodo(const char *text, void *ud);
static int hookPermission(const char *tool, const char *detail,
const char *preview, void *ud);
static int hookAskUser(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud);
/* Post a lambda onto the GUI thread. */
template <typename F>
void post(F f) {
QMetaObject::invokeMethod(this, f, Qt::QueuedConnection);
}
cst_agent_t *m_agent = nullptr;
cst_agent_config_t m_cfg;
cst_agent_hooks_t m_hooks;
QString m_workspace;
QThread *m_turn = nullptr;
bool m_running = false;
bool m_closing = false;
/* consent handshake between the worker and the GUI thread */
QMutex m_askMu;
QWaitCondition m_askCv;
int m_permAnswer = CST_PERM_DENY;
bool m_permPending = false;
QString m_question;
QString m_todo;
/* The session list rebuilt only when a transcript moved: filling one row
* walks the whole file, and the sidebar asks for the list on every store
* change. Keyed by id, dropped when the id is gone. */
struct SessionCache {
qint64 mtime = 0;
qint64 size = 0;
QVariantMap row;
};
mutable QHash<QString, SessionCache> m_sessionCache;
/* Names of the files this window attached, in the engine's pending order:
* the engine's own description says what it is, not where it came from. */
QStringList m_attachNames;
QByteArray m_answer;
bool m_questionPending = false;
bool m_autoApprove = false;
};
#endif /* CST_APP_AGENTCORE_H */