v0.8.7: tool capability metadata, in-process engine, Qt app, Android engine

The engine can be embedded now instead of only driven from a terminal: the
same objects link into a Qt front end (app/) that owns a cst_agent_t inside
its own process -- no port, no token, no second process -- and that front end
now runs on the desktop and, engine-side, on the phone.

Capability metadata (include/cst_tools.h, src/tools/registry.c):
- cst_tool_def_t gains perm_name, platforms and available (a probe that
  returns a reason), so a permission is addressable by name and a tool can
  say why it cannot run here.
- cst_platform_name(), cst_tool_platform_ok(), cst_tool_available(),
  cst_tool_meta_reload(), cst_tool_perm_name() and CST_PLAT_DESKTOP[].
- cst_tool_register() may take over a built-in name that is not runnable on
  this platform, so a front end replaces it with its own implementation.
- the registry entries carry their class (delete_file -> "delete"), the
  desktop clipboard tools carry a clipboard probe; find/active/run gate on it.
- /api/settings.tools[] exposes perm_name, platforms, available, why and the
  declared parameters.

Engine as a library (Makefile, CMakeLists.txt):
- libcstcore.a from the CORE_OBJ set, and a CMake cstcore target shared by
  the CLI and by app/ (-DCST_BUILD_APP=ON): the engine is compiled once.
- Android branch for the vendored sashidown: a per-ABI archive built with the
  NDK's aarch64-linux-android24-clang wrapper, because CMake's compiler is
  plain clang and keeps --target/--sysroot in its own variables.

Qt front end (app/, new):
- AgentCore wraps one cst_agent_t: turns run on a worker thread, every
  cst_agent_hooks_t callback becomes a Qt signal, and the two hooks that need
  an answer (on_permission, on_ask_user) block the worker until the GUI
  replies; mode, approval and plan mode go through the engine's config.
- ToolProvider hands the front end's tools to the engine (owner "app:*"); the
  Linux one is notification_send with a probe for notify-send/D-Bus.
- Main.qml: transcript, tool blocks, consent dialog (Deny/Once/Always ->
  CST_PERM_*), ask_user, mode switch, Esc to cancel; --selftest stays
  headless and runs the embedding checks without QML.

Android (scripts/, docs/android.md, new):
- scripts/build_third_party_android.sh builds static mbedTLS + libcurl for an
  ABI (CA path /system/etc/security/cacerts); build_android.sh builds
  cst-agent, libcstcore.a and the test binaries for the phone, can push them
  and run the whole suite there.
- sandbox_exec.c keeps the plain exec path on Android: the seccomp supervisor
  compiled in and the probe reported "seccomp", but writes outside the
  workspace were not denied on the device -- claiming a sandbox that does not
  hold is worse than reporting none.
- tests/test_diff.c compares layouts only against real GNU diffutils: toybox's
  diff prints the same hunk header with its own hunk/context rules.

Tests: test_tool_meta (metadata, platform gate, probe reasons, takeover of a
built-in name) and test_embed (in-process embedding contract) are new; the
existing registry/llm_body assertions now follow availability. 38 test
binaries pass on the host, 36/36 on arm64-v8a Android 16, cst-app-selftest
passes in both the standalone app/ build and the repo build.

Version 0.8.7.
This commit is contained in:
WHC
2026-10-11 11:42:04 +08:00
parent e0b0eddf3a
commit 5ba215b36c
28 changed files with 2452 additions and 34 deletions
+283
View File
@@ -0,0 +1,283 @@
#include "AgentCore.h"
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QThread>
/* --- 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());
/* 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();
return true;
}
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<Turn *>(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<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;
}
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;
}
/* --- 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 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<AgentCore *>(ud);
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, n, exit_code, o] { emit self->toolResult(n, exit_code, o); });
}
void AgentCore::hookTodo(const char *text, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString t = QString::fromUtf8(text ? text : "");
self->post([self, t] { emit self->todoChanged(t); });
}