forked from WHC/Hermit
- 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.
286 lines
14 KiB
C++
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 */
|