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.
This commit is contained in:
@@ -325,7 +325,8 @@ cst-agent --headless --prompt 'List files'
|
|||||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
||||||
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
||||||
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
||||||
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) |
|
| `md_to_pdf` | 文档 | 图形前端(桌面 / APK)自带:把 markdown 或工作区 `.md` / `.html` 排成 A4 PDF(15 mm 边距,自动分页)写进工作区并交付成文件卡;不需要 Qt PrintSupport,手机上也能出 |
|
||||||
|
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文);有预算:`max_bytes`(默认 32 MB)、`max_seconds`(默认 5 s),超 64 KiB 的单行跳过,被预算截断时结果里写清楚 |
|
||||||
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
||||||
|
|
||||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
||||||
|
|||||||
+5
-2
@@ -43,8 +43,10 @@ if(NOT TARGET cstcore)
|
|||||||
list(REMOVE_ITEM CST_APP_CORE_SRC
|
list(REMOVE_ITEM CST_APP_CORE_SRC
|
||||||
"${CST_APP_ROOT}/src/net.c"
|
"${CST_APP_ROOT}/src/net.c"
|
||||||
"${CST_APP_ROOT}/src/http_srv.c"
|
"${CST_APP_ROOT}/src/http_srv.c"
|
||||||
"${CST_APP_ROOT}/src/webui.c"
|
"${CST_APP_ROOT}/src/webui.c")
|
||||||
"${CST_APP_ROOT}/src/web_md.c")
|
# web_md.c stays in: the window renders markdown with it (AgentCore::
|
||||||
|
# renderMarkdown) and the md_to_html tool needs it. Only the HTTP server
|
||||||
|
# is left out.
|
||||||
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
|
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
|
||||||
include_directories("${CST_APP_ROOT}/.gen")
|
include_directories("${CST_APP_ROOT}/.gen")
|
||||||
endif()
|
endif()
|
||||||
@@ -100,6 +102,7 @@ if(CST_APP_GUI)
|
|||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/core/AgentCore.cpp
|
src/core/AgentCore.cpp
|
||||||
src/core/ToolProvider.cpp
|
src/core/ToolProvider.cpp
|
||||||
|
src/core/PdfTools.cpp
|
||||||
src/ui/UiTest.cpp
|
src/ui/UiTest.cpp
|
||||||
src/ui/ThemeIcons.cpp
|
src/ui/ThemeIcons.cpp
|
||||||
src/ui/SessionImages.cpp
|
src/ui/SessionImages.cpp
|
||||||
|
|||||||
@@ -296,6 +296,26 @@ and two buttons.
|
|||||||
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
||||||
and checks a name the session does not own is refused.
|
and checks a name the session does not own is refused.
|
||||||
|
|
||||||
|
### Making one: `md_to_pdf`
|
||||||
|
|
||||||
|
The front end registers a tool of its own for this (`app/src/core/PdfTools.cpp`,
|
||||||
|
owner `app:qt`, `platforms = NULL` so every platform the window runs on gets
|
||||||
|
it): markdown, or a workspace `.md`/`.html` file, becomes an A4 PDF with 15 mm
|
||||||
|
margins in the workspace, and goes through the same
|
||||||
|
`cst_attach_import_artifact()` path as `deliver_file` -- so it arrives as a
|
||||||
|
file card the person can open, print or share, and its bytes never enter a
|
||||||
|
request. The markdown is rendered by the engine (`cst_web_md_document()`, the
|
||||||
|
same renderer the WebUI and `md_to_html` use) and Qt lays the HTML out
|
||||||
|
(`QTextDocument` + `QPdfWriter`, both QtGui); pagination is written here,
|
||||||
|
page rect by page rect, because `QPrinter`/PrintSupport is desktop-only and a
|
||||||
|
phone has no print subsystem. The tool runs on the turn's worker thread, and
|
||||||
|
`--uitest` runs it through the registry and checks the file starts with
|
||||||
|
`%PDF-`.
|
||||||
|
|
||||||
|
The card row on the phone is the same one a picture uses: the store keeps the
|
||||||
|
PDF next to the session, the image provider refuses it (it is not an image),
|
||||||
|
and the file card carries it.
|
||||||
|
|
||||||
## Transcript, tool calls, streaming text
|
## Transcript, tool calls, streaming text
|
||||||
|
|
||||||
- A tool call is a card of its own, and the call **id** is what pairs it with
|
- A tool call is a card of its own, and the call **id** is what pairs it with
|
||||||
|
|||||||
@@ -650,6 +650,12 @@ ApplicationWindow {
|
|||||||
ask.open()
|
ask.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nobody answered (the app was in the background, or the dialog sat
|
||||||
|
* there too long): the turn went on without it, so put the dialog
|
||||||
|
* away instead of leaving a question that is no longer being asked. */
|
||||||
|
function onPermissionExpired(tool) { consent.close() }
|
||||||
|
function onQuestionExpired() { ask.close() }
|
||||||
|
|
||||||
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
||||||
|
|
||||||
function onRunningChanged() {
|
function onRunningChanged() {
|
||||||
|
|||||||
+127
-13
@@ -189,6 +189,12 @@ void AgentCore::close() {
|
|||||||
/* --- turns --------------------------------------------------------------- */
|
/* --- turns --------------------------------------------------------------- */
|
||||||
|
|
||||||
namespace {
|
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"
|
/* The first line of a prompt, cut short: the line the phone's "working"
|
||||||
* notification carries while the turn runs (app::holdForeground). */
|
* notification carries while the turn runs (app::holdForeground). */
|
||||||
QString firstLine(const QString &s) {
|
QString firstLine(const QString &s) {
|
||||||
@@ -224,9 +230,28 @@ void AgentCore::cancel() {
|
|||||||
cst_agent_request_cancel(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<QGuiApplication *>(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) {
|
void AgentCore::finishTurn(int rc, const QString &err) {
|
||||||
Turn *t = static_cast<Turn *>(sender());
|
Turn *t = static_cast<Turn *>(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] {
|
post([this, rc, err] {
|
||||||
|
m_turn = nullptr;
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
||||||
/* The prompt carried whatever was pending; the engine dropped it. */
|
/* The prompt carried whatever was pending; the engine dropped it. */
|
||||||
@@ -234,11 +259,6 @@ void AgentCore::finishTurn(int rc, const QString &err) {
|
|||||||
emit pendingChanged();
|
emit pendingChanged();
|
||||||
emit turnFinished(rc, err);
|
emit turnFinished(rc, err);
|
||||||
});
|
});
|
||||||
if (t) {
|
|
||||||
t->wait();
|
|
||||||
t->deleteLater();
|
|
||||||
}
|
|
||||||
m_turn = nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AgentCore::setRunning(bool on) {
|
void AgentCore::setRunning(bool on) {
|
||||||
@@ -275,16 +295,43 @@ int AgentCore::hookPermission(const char *tool, const char *detail,
|
|||||||
return CST_PERM_DENY;
|
return CST_PERM_DENY;
|
||||||
if (self->m_autoApprove)
|
if (self->m_autoApprove)
|
||||||
return CST_PERM_ONCE;
|
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);
|
QMutexLocker lock(&self->m_askMu);
|
||||||
self->m_permAnswer = CST_PERM_DENY;
|
self->m_permAnswer = CST_PERM_DENY;
|
||||||
self->m_permPending = true;
|
self->m_permPending = true;
|
||||||
const QString t = QString::fromUtf8(tool ? tool : "");
|
|
||||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
const QString d = QString::fromUtf8(detail ? detail : "");
|
||||||
const QString p = QString::fromUtf8(preview ? preview : "");
|
const QString p = QString::fromUtf8(preview ? preview : "");
|
||||||
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
||||||
while (self->m_permPending && !self->m_closing)
|
/* And even in the foreground nobody may hang forever: a dialog can be
|
||||||
self->m_askCv.wait(&self->m_askMu);
|
* 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;
|
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;
|
return self->m_permAnswer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,16 +342,40 @@ int AgentCore::hookAskUser(const char *question, const char *options, char *ans,
|
|||||||
return -1;
|
return -1;
|
||||||
if (self->m_autoApprove)
|
if (self->m_autoApprove)
|
||||||
return -1;
|
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);
|
QMutexLocker lock(&self->m_askMu);
|
||||||
self->m_questionPending = true;
|
self->m_questionPending = true;
|
||||||
self->m_answer.clear();
|
self->m_answer.clear();
|
||||||
const QString q = QString::fromUtf8(question ? question : "");
|
const QString q = QString::fromUtf8(question ? question : "");
|
||||||
const QString o = QString::fromUtf8(options ? options : "");
|
const QString o = QString::fromUtf8(options ? options : "");
|
||||||
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
||||||
while (self->m_questionPending && !self->m_closing)
|
bool expired = false;
|
||||||
self->m_askCv.wait(&self->m_askMu);
|
while (self->m_questionPending && !self->m_closing) {
|
||||||
|
if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) {
|
||||||
|
expired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (self->m_closing)
|
if (self->m_closing)
|
||||||
return -1;
|
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());
|
snprintf(ans, ans_sz, "%s", self->m_answer.constData());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -530,29 +601,56 @@ QString AgentCore::shareArtifact(const QString &rel) {
|
|||||||
|
|
||||||
QVariantList AgentCore::sessions() const {
|
QVariantList AgentCore::sessions() const {
|
||||||
QVariantList out;
|
QVariantList out;
|
||||||
if (m_workspace.isEmpty())
|
if (m_workspace.isEmpty()) {
|
||||||
|
m_sessionCache.clear();
|
||||||
return out;
|
return out;
|
||||||
|
}
|
||||||
const QByteArray ws = m_workspace.toUtf8();
|
const QByteArray ws = m_workspace.toUtf8();
|
||||||
const int kMax = 200;
|
const int kMax = 200;
|
||||||
char(*ids)[128] = new char[kMax][128];
|
char(*ids)[128] = new char[kMax][128];
|
||||||
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
||||||
const QString current = sessionId();
|
const QString current = sessionId();
|
||||||
|
QHash<QString, SessionCache> fresh;
|
||||||
|
fresh.reserve(n);
|
||||||
for (int i = 0; i < n; i++) {
|
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;
|
cst_session_info_t in;
|
||||||
memset(&in, 0, sizeof(in));
|
memset(&in, 0, sizeof(in));
|
||||||
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
||||||
continue;
|
continue;
|
||||||
const QString id = utf8(in.id[0] ? in.id : ids[i]);
|
entry.mtime = mtime;
|
||||||
|
entry.size = size;
|
||||||
QVariantMap m;
|
QVariantMap m;
|
||||||
m["id"] = id;
|
m["id"] = id;
|
||||||
m["title"] = utf8(in.title);
|
m["title"] = utf8(in.title);
|
||||||
m["firstUser"] = utf8(in.first_user);
|
m["firstUser"] = utf8(in.first_user);
|
||||||
m["messages"] = in.messages;
|
m["messages"] = in.messages;
|
||||||
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
||||||
|
entry.row = m;
|
||||||
|
}
|
||||||
|
QVariantMap m = entry.row;
|
||||||
m["current"] = (id == current);
|
m["current"] = (id == current);
|
||||||
out.append(m);
|
out.append(m);
|
||||||
|
fresh.insert(id, entry);
|
||||||
}
|
}
|
||||||
delete[] ids;
|
delete[] ids;
|
||||||
|
m_sessionCache = fresh; /* and the ids that are gone are forgotten */
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +808,9 @@ bool AgentCore::deleteSession(const QString &id) {
|
|||||||
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
||||||
if (id.isEmpty() || m_workspace.isEmpty())
|
if (id.isEmpty() || m_workspace.isEmpty())
|
||||||
return false;
|
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(),
|
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
|
||||||
t = title.toUtf8();
|
t = title.toUtf8();
|
||||||
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
||||||
@@ -1138,7 +1239,20 @@ void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
|||||||
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
||||||
const QString n = QString::fromUtf8(call ? call->name : "");
|
const QString n = QString::fromUtf8(call ? call->name : "");
|
||||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
const QString d = QString::fromUtf8(detail ? detail : "");
|
||||||
self->post([self, p, id, n, d] { emit self->toolEvent(p, id, n, d); });
|
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,
|
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#define CST_APP_AGENTCORE_H
|
#define CST_APP_AGENTCORE_H
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
#include <QMutex>
|
#include <QMutex>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
@@ -204,6 +205,11 @@ class AgentCore : public QObject {
|
|||||||
void permissionRequested(const QString &tool, const QString &detail,
|
void permissionRequested(const QString &tool, const QString &detail,
|
||||||
const QString &preview);
|
const QString &preview);
|
||||||
void questionAsked(const QString &question, const QString &options);
|
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 turnFinished(int rc, const QString &err);
|
||||||
void runningChanged();
|
void runningChanged();
|
||||||
void workspaceChanged();
|
void workspaceChanged();
|
||||||
@@ -218,6 +224,10 @@ class AgentCore : public QObject {
|
|||||||
void syncConfig();
|
void syncConfig();
|
||||||
/* Re-read what a session switch changed (id, todo) and say so. */
|
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||||
void afterSessionChange();
|
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). */
|
/* Hook trampolines (C, run on the worker thread). */
|
||||||
static void hookStatus(const char *msg, void *ud);
|
static void hookStatus(const char *msg, void *ud);
|
||||||
@@ -255,6 +265,15 @@ class AgentCore : public QObject {
|
|||||||
bool m_permPending = false;
|
bool m_permPending = false;
|
||||||
QString m_question;
|
QString m_question;
|
||||||
QString m_todo;
|
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:
|
/* 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. */
|
* the engine's own description says what it is, not where it came from. */
|
||||||
QStringList m_attachNames;
|
QStringList m_attachNames;
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
/* The window's own tools, in Qt rather than in the engine: a document the
|
||||||
|
* person asked for, laid out as a PDF.
|
||||||
|
*
|
||||||
|
* Why here and not in the C engine: laying text out on pages is exactly what
|
||||||
|
* Qt does (QTextDocument + QPdfWriter, both in QtGui, which the app already
|
||||||
|
* links), while the engine has no layout code at all -- and md_to_html, its
|
||||||
|
* sibling, lives in the engine because rendering markdown is a shared
|
||||||
|
* capability. This one is a front-end capability: it is registered under
|
||||||
|
* owner "app:qt" and is available on every platform the app runs on.
|
||||||
|
*
|
||||||
|
* The path of a document is the one md_to_html walks: markdown (or a
|
||||||
|
* workspace .md/.html file) -> cst_web_md_document() -> a file inside the
|
||||||
|
* workspace -> cst_attach_import_artifact(), which is what turns it into a
|
||||||
|
* file card the person can open or share. The bytes never reach the model.
|
||||||
|
*
|
||||||
|
* Pagination is ours on purpose: QPrinter/PrintSupport is a desktop-only
|
||||||
|
* module (a phone has no print subsystem), and a round of
|
||||||
|
* pageSize + translate + drawContents per page is all a paginated PDF needs.
|
||||||
|
* The tool runs on the turn's worker thread, so a long document does not
|
||||||
|
* freeze the window. */
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QMarginsF>
|
||||||
|
#include <QPageLayout>
|
||||||
|
#include <QPageSize>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPdfWriter>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QString>
|
||||||
|
#include <QTextDocument>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "core/AgentCore.h"
|
||||||
|
#include "core/ToolProvider.h"
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "cst_tools.h"
|
||||||
|
#include "cst_web_md.h"
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/* md_to_html caps its input the same way: a document nobody could read is
|
||||||
|
* not worth laying out. */
|
||||||
|
constexpr int kMaxInputBytes = 8 * 1024 * 1024;
|
||||||
|
/* 150 dpi: crisp enough for text, and a good deal lighter on a phone than the
|
||||||
|
* default 300 (a 40-page document is laid out and written in a moment). */
|
||||||
|
constexpr int kPdfDpi = 150;
|
||||||
|
|
||||||
|
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||||
|
for (int i = 0; i < call->nargs; i++)
|
||||||
|
if (strcmp(call->args[i].key, key) == 0)
|
||||||
|
return call->args[i].value;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int setOut(cst_tool_result_t *r, const QString &text, int rc) {
|
||||||
|
const QByteArray b = text.toUtf8();
|
||||||
|
r->out = strdup(b.constData());
|
||||||
|
r->out_len = r->out ? strlen(r->out) : 0;
|
||||||
|
r->exit_code = rc;
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "docs/notes.md" -> "notes". */
|
||||||
|
QString stemOf(const QString &path) {
|
||||||
|
QString base = path;
|
||||||
|
const int slash = qMax(base.lastIndexOf(QLatin1Char('/')),
|
||||||
|
base.lastIndexOf(QLatin1Char('\\')));
|
||||||
|
if (slash >= 0)
|
||||||
|
base = base.mid(slash + 1);
|
||||||
|
const int dot = base.lastIndexOf(QLatin1Char('.'));
|
||||||
|
if (dot > 0)
|
||||||
|
base = base.left(dot);
|
||||||
|
return base.isEmpty() ? QStringLiteral("document") : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What the file is called: `out` when given (a directory takes the source's
|
||||||
|
* name inside it), otherwise the source's stem, or the title. */
|
||||||
|
QString outPathOf(const ctl_tool_call_t *c, const QString &stem) {
|
||||||
|
const char *out = argOf(c, "out");
|
||||||
|
QString name = out && out[0] ? QString::fromUtf8(out) : QString();
|
||||||
|
if (name.isEmpty())
|
||||||
|
return stem + QStringLiteral(".pdf");
|
||||||
|
if (name.endsWith(QLatin1Char('/')) || name.endsWith(QLatin1Char('\\')))
|
||||||
|
return name + stem + QStringLiteral(".pdf");
|
||||||
|
if (!QFileInfo(name).fileName().contains(QLatin1Char('.')))
|
||||||
|
name += QStringLiteral(".pdf");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The HTML that goes into the page: an .html file is already a document, a
|
||||||
|
* .md file (or the `markdown` argument) goes through the engine's renderer,
|
||||||
|
* which is the same one the WebUI and md_to_html use. */
|
||||||
|
bool sourceHtml(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
|
QString *html, QString *out_name, QString *err) {
|
||||||
|
const char *md = argOf(c, "markdown");
|
||||||
|
const char *file = argOf(c, "file");
|
||||||
|
const char *given = argOf(c, "title");
|
||||||
|
QString title = given && given[0] ? QString::fromUtf8(given) : QString();
|
||||||
|
QByteArray text;
|
||||||
|
QString stem;
|
||||||
|
if (file && file[0]) {
|
||||||
|
char rel[4096] = "";
|
||||||
|
if (cst_sandbox_resolve(ctx->workspace_root, file, rel, sizeof(rel)) != 0) {
|
||||||
|
*err = QStringLiteral("error: path outside the workspace or missing "
|
||||||
|
"parent: %1")
|
||||||
|
.arg(QString::fromUtf8(file));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QFile in(QString::fromUtf8(rel));
|
||||||
|
if (!in.open(QIODevice::ReadOnly)) {
|
||||||
|
*err = QStringLiteral("error: cannot read %1")
|
||||||
|
.arg(QString::fromUtf8(file));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (in.size() > kMaxInputBytes) {
|
||||||
|
*err = QStringLiteral("error: %1 is larger than %2 MB")
|
||||||
|
.arg(QString::fromUtf8(file))
|
||||||
|
.arg(kMaxInputBytes >> 20);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
text = in.readAll();
|
||||||
|
stem = stemOf(QString::fromUtf8(file));
|
||||||
|
const QString lower = QString::fromUtf8(file).toLower();
|
||||||
|
if (lower.endsWith(QLatin1String(".html")) ||
|
||||||
|
lower.endsWith(QLatin1String(".htm"))) {
|
||||||
|
/* already a document: what md_to_html wrote, or anything else */
|
||||||
|
*html = QString::fromUtf8(text);
|
||||||
|
if (title.isEmpty())
|
||||||
|
title = stem;
|
||||||
|
*out_name = outPathOf(c, stem);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (md && md[0]) {
|
||||||
|
text = QByteArray(md);
|
||||||
|
if (stem.isEmpty())
|
||||||
|
stem = title.isEmpty() ? QStringLiteral("document") : stemOf(title);
|
||||||
|
} else {
|
||||||
|
*err = QStringLiteral("error: give markdown, or a workspace .md or .html "
|
||||||
|
"file to render");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (title.isEmpty())
|
||||||
|
title = stem;
|
||||||
|
char *doc =
|
||||||
|
cst_web_md_document(text.constData(), static_cast<size_t>(text.size()),
|
||||||
|
title.toUtf8().constData());
|
||||||
|
if (!doc) {
|
||||||
|
*err = QStringLiteral("error: cannot render the markdown");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*html = QString::fromUtf8(doc);
|
||||||
|
free(doc);
|
||||||
|
*out_name = outPathOf(c, stem);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Write `html` into `path` as an A4 PDF with 15 mm margins: one page rect,
|
||||||
|
* translated down per page, and the document is asked for exactly that much
|
||||||
|
* text at a time -- which is what makes Qt lay out pages instead of one long
|
||||||
|
* strip. */
|
||||||
|
bool writePdf(const QString &html, const QString &path, const QString &title,
|
||||||
|
QString *err) {
|
||||||
|
QTextDocument doc;
|
||||||
|
doc.setHtml(html);
|
||||||
|
if (!title.isEmpty())
|
||||||
|
doc.setMetaInformation(QTextDocument::DocumentTitle, title);
|
||||||
|
|
||||||
|
QPdfWriter writer(path);
|
||||||
|
writer.setPageSize(QPageSize(QPageSize::A4));
|
||||||
|
writer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout::Millimeter);
|
||||||
|
writer.setResolution(kPdfDpi);
|
||||||
|
if (!title.isEmpty())
|
||||||
|
writer.setTitle(title);
|
||||||
|
|
||||||
|
QPainter painter(&writer);
|
||||||
|
if (!painter.isActive()) {
|
||||||
|
*err = QStringLiteral("error: cannot draw into %1").arg(path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QRectF page = writer.pageLayout().paintRectPixels(writer.resolution());
|
||||||
|
doc.setPageSize(page.size());
|
||||||
|
const int pages = qMax(1, doc.pageCount());
|
||||||
|
for (int i = 0; i < pages; i++) {
|
||||||
|
if (i)
|
||||||
|
writer.newPage();
|
||||||
|
painter.save();
|
||||||
|
painter.translate(page.topLeft());
|
||||||
|
painter.translate(0, -i * page.height());
|
||||||
|
doc.drawContents(&painter,
|
||||||
|
QRectF(0, 0, page.width(), page.height())
|
||||||
|
.translated(0, i * page.height()));
|
||||||
|
painter.restore();
|
||||||
|
}
|
||||||
|
painter.end();
|
||||||
|
/* The painter says nothing about the file: the only proof the PDF landed is
|
||||||
|
* that there is one with bytes in it. */
|
||||||
|
if (QFileInfo(path).size() <= 0) {
|
||||||
|
*err = QStringLiteral("error: nothing was written to %1").arg(path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mdToPdfPreview(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
|
char **out) {
|
||||||
|
(void)ctx;
|
||||||
|
const char *md = argOf(c, "markdown");
|
||||||
|
const char *file = argOf(c, "file");
|
||||||
|
if ((!md || !md[0]) && (!file || !file[0])) {
|
||||||
|
*out = strdup("error: give markdown, or a .md or .html file to render");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const QString stem =
|
||||||
|
file && file[0] ? stemOf(QString::fromUtf8(file)) : QStringLiteral("document");
|
||||||
|
const QString name = outPathOf(c, stem);
|
||||||
|
QByteArray line;
|
||||||
|
if (file && file[0])
|
||||||
|
line = QStringLiteral("lay out %1 → %2 as a PDF\n")
|
||||||
|
.arg(QString::fromUtf8(file), name)
|
||||||
|
.toUtf8();
|
||||||
|
else
|
||||||
|
line = QStringLiteral("lay out %1 bytes of markdown → %2 as a PDF\n")
|
||||||
|
.arg(md ? qstrlen(md) : 0)
|
||||||
|
.arg(name)
|
||||||
|
.toUtf8();
|
||||||
|
*out = strdup(line.constData());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mdToPdfRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
|
cst_tool_result_t *r, void *ud) {
|
||||||
|
(void)ud;
|
||||||
|
QString html, out_name, err;
|
||||||
|
if (!sourceHtml(ctx, c, &html, &out_name, &err))
|
||||||
|
return setOut(r, err, 1);
|
||||||
|
|
||||||
|
char full[4096] = "";
|
||||||
|
if (cst_sandbox_resolve(ctx->workspace_root, out_name.toUtf8().constData(),
|
||||||
|
full, sizeof(full)) != 0)
|
||||||
|
return setOut(r, QStringLiteral("error: path outside the workspace: %1")
|
||||||
|
.arg(out_name),
|
||||||
|
1);
|
||||||
|
const QString target = QString::fromUtf8(full);
|
||||||
|
/* A directory in the way of the name is what the engine's write tools call
|
||||||
|
* "missing parent": make it, the same way md_to_html does. */
|
||||||
|
QDir().mkpath(QFileInfo(target).absolutePath());
|
||||||
|
|
||||||
|
const char *given = argOf(c, "title");
|
||||||
|
const QString title = given && given[0] ? QString::fromUtf8(given) : QString();
|
||||||
|
if (!writePdf(html, target, title, &err))
|
||||||
|
return setOut(r, err, 1);
|
||||||
|
|
||||||
|
const qint64 bytes = QFileInfo(target).size();
|
||||||
|
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : nullptr;
|
||||||
|
cst_attachment_t *a = nullptr;
|
||||||
|
char aerr[256] = "";
|
||||||
|
if (session && session[0])
|
||||||
|
a = cst_attach_import_artifact(session, full, nullptr, aerr, sizeof(aerr));
|
||||||
|
|
||||||
|
QString text = QStringLiteral("wrote %1 (%2 KB, A4, 15 mm margins)\n")
|
||||||
|
.arg(out_name)
|
||||||
|
.arg(bytes / 1024);
|
||||||
|
if (a)
|
||||||
|
text += QStringLiteral("(the user sees it as a file card they can open or "
|
||||||
|
"share; you will not be shown its bytes again)\n");
|
||||||
|
else if (aerr[0])
|
||||||
|
text += QStringLiteral("(the file is in the workspace; handing it over "
|
||||||
|
"failed: %1)\n")
|
||||||
|
.arg(QString::fromUtf8(aerr));
|
||||||
|
const int rc = setOut(r, text, 0);
|
||||||
|
if (a) {
|
||||||
|
r->attach = a;
|
||||||
|
r->nattach = 1;
|
||||||
|
}
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int registerQtTools(AgentCore *core) {
|
||||||
|
if (!core)
|
||||||
|
return 0;
|
||||||
|
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||||
|
{"markdown", "string", 0, "markdown text to render"},
|
||||||
|
{"file", "string", 0,
|
||||||
|
"workspace .md file to render, or an .html one to lay out as it is"},
|
||||||
|
{"out", "string", 0,
|
||||||
|
"output path in the workspace (default: the source's name + .pdf)"},
|
||||||
|
{"title", "string", 0, "document title (default: the file name)"},
|
||||||
|
{nullptr, nullptr, 0, nullptr},
|
||||||
|
{nullptr, nullptr, 0, nullptr}};
|
||||||
|
static cst_tool_def_t def;
|
||||||
|
memset(&def, 0, sizeof(def));
|
||||||
|
setMeta(&def, "md_to_pdf",
|
||||||
|
"Write a document as a PDF: markdown (or a workspace .md/.html "
|
||||||
|
"file) in, a laid-out A4 PDF out, saved in the workspace and "
|
||||||
|
"handed to the person as a card they can open, print or share. "
|
||||||
|
"Use it when the answer should be something to keep or send.",
|
||||||
|
CST_TP_WRITE, "pdf", nullptr, nullptr);
|
||||||
|
memcpy(def.params, params, sizeof(params));
|
||||||
|
def.run_ex = mdToPdfRun;
|
||||||
|
def.preview = mdToPdfPreview;
|
||||||
|
def.owner = "app:qt";
|
||||||
|
return core->registerTool(&def) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
@@ -22,6 +22,8 @@ int registerPlatformTools(AgentCore *core) {
|
|||||||
#elif defined(__linux__)
|
#elif defined(__linux__)
|
||||||
n += platformRegisterLinux(core);
|
n += platformRegisterLinux(core);
|
||||||
#endif
|
#endif
|
||||||
|
/* Qt is not a platform API: these exist wherever the window does. */
|
||||||
|
n += registerQtTools(core);
|
||||||
/* A service may have appeared or gone since the last probe (a desktop
|
/* A service may have appeared or gone since the last probe (a desktop
|
||||||
* session starting, the notification daemon coming up). */
|
* session starting, the notification daemon coming up). */
|
||||||
cst_tool_meta_reload();
|
cst_tool_meta_reload();
|
||||||
@@ -35,6 +37,7 @@ void unregisterPlatformTools(AgentCore *core) {
|
|||||||
core->unregisterOwner(QStringLiteral("app:windows"));
|
core->unregisterOwner(QStringLiteral("app:windows"));
|
||||||
core->unregisterOwner(QStringLiteral("app:macos"));
|
core->unregisterOwner(QStringLiteral("app:macos"));
|
||||||
core->unregisterOwner(QStringLiteral("app:android"));
|
core->unregisterOwner(QStringLiteral("app:android"));
|
||||||
|
core->unregisterOwner(QStringLiteral("app:qt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace app
|
} // namespace app
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ extern const char *const kDesktopOnly[];
|
|||||||
/* The platform tools this app provides. Called once the engine exists;
|
/* The platform tools this app provides. Called once the engine exists;
|
||||||
* returns how many were registered. */
|
* returns how many were registered. */
|
||||||
int registerPlatformTools(AgentCore *core);
|
int registerPlatformTools(AgentCore *core);
|
||||||
|
/* The tools this front end provides that are the same on every platform
|
||||||
|
* (they are Qt, not a platform API): today md_to_pdf, owned by "app:qt".
|
||||||
|
* Called next to registerPlatformTools(). */
|
||||||
|
int registerQtTools(AgentCore *core);
|
||||||
/* Drop them again (they belong to the owners "app:*"). */
|
/* Drop them again (they belong to the owners "app:*"). */
|
||||||
void unregisterPlatformTools(AgentCore *core);
|
void unregisterPlatformTools(AgentCore *core);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QJniObject>
|
#include <QJniObject>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
|
#include <QSemaphore>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/AgentCore.h"
|
#include "core/AgentCore.h"
|
||||||
@@ -59,16 +61,34 @@ void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
|
|||||||
r->exit_code = rc;
|
r->exit_code = rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* How long the worker is willing to wait for the GUI thread to pick its work
|
||||||
|
* up. The hop itself is always waited for to the end (the closure writes into
|
||||||
|
* the caller's stack), so what this bounds is the case that makes a turn look
|
||||||
|
* stuck: a GUI thread that is not getting to the event loop at all. */
|
||||||
|
constexpr int kGuiStallMs = 3000;
|
||||||
|
|
||||||
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
||||||
* run on the GUI thread; the turn's worker thread waits for it. */
|
* run on the GUI thread; the turn's worker thread waits for it.
|
||||||
|
*
|
||||||
|
* The wait is bounded: a blocking queued call to a GUI thread that is busy
|
||||||
|
* (the session list reading transcripts used to do exactly that) would park
|
||||||
|
* the worker for as long as it lasts. A cheap ping first tells whether the
|
||||||
|
* GUI thread is there at all; only then is the real work handed over and
|
||||||
|
* waited for, which keeps the closure valid for its whole life. */
|
||||||
template <typename Fn>
|
template <typename Fn>
|
||||||
void onGuiThread(Fn &&fn) {
|
bool onGuiThread(Fn &&fn) {
|
||||||
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
||||||
fn();
|
fn();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
auto pinged = std::make_shared<QSemaphore>(0);
|
||||||
|
QMetaObject::invokeMethod(qApp, [pinged] { pinged->release(); },
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
if (!pinged->tryAcquire(1, kGuiStallMs))
|
||||||
|
return false; /* a GUI thread that is not answering: do not wait on it */
|
||||||
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
||||||
Qt::BlockingQueuedConnection);
|
Qt::BlockingQueuedConnection);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
jint sdkInt() {
|
jint sdkInt() {
|
||||||
|
|||||||
@@ -416,6 +416,53 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
QStringLiteral("and the line is where the card is"));
|
QStringLiteral("and the line is where the card is"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- md_to_pdf: the front end's own document writer ------------------- */
|
||||||
|
{
|
||||||
|
const cst_tool_def_t *def = cst_tool_find("md_to_pdf");
|
||||||
|
check(def != nullptr && def->owner != nullptr &&
|
||||||
|
strncmp(def->owner, "app:qt", 6) == 0,
|
||||||
|
QStringLiteral("md_to_pdf is registered by the front end"));
|
||||||
|
if (def && def->run_ex) {
|
||||||
|
/* Run it the way the agent's turn would: through the registry, with a
|
||||||
|
* call and the engine's context. */
|
||||||
|
const QByteArray ws = core.workspace().toUtf8();
|
||||||
|
cst_tool_ctx_t ctx;
|
||||||
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.workspace_root = ws.constData();
|
||||||
|
ctx.agent = core.agent();
|
||||||
|
ctl_tool_call_t call;
|
||||||
|
memset(&call, 0, sizeof(call));
|
||||||
|
snprintf(call.id, sizeof(call.id), "pdf1");
|
||||||
|
snprintf(call.name, sizeof(call.name), "md_to_pdf");
|
||||||
|
static const char *kMd =
|
||||||
|
"# 设备信息报告\n\n这是一段**中文**正文,用来确认字体和分页。\n\n"
|
||||||
|
"- 项目一\n- 项目二\n\n```\ncode block\n```\n";
|
||||||
|
static const char *kOut = "selftest.pdf";
|
||||||
|
snprintf(call.args[0].key, sizeof(call.args[0].key), "markdown");
|
||||||
|
call.args[0].value = const_cast<char *>(kMd);
|
||||||
|
call.args[0].value_len = strlen(kMd);
|
||||||
|
snprintf(call.args[1].key, sizeof(call.args[1].key), "out");
|
||||||
|
call.args[1].value = const_cast<char *>(kOut);
|
||||||
|
call.args[1].value_len = strlen(kOut);
|
||||||
|
call.nargs = 2;
|
||||||
|
cst_tool_result_t res;
|
||||||
|
memset(&res, 0, sizeof(res));
|
||||||
|
const int rc = cst_tool_run(&ctx, &call, &res);
|
||||||
|
check(res.exit_code == 0,
|
||||||
|
QStringLiteral("md_to_pdf writes the document (%1)")
|
||||||
|
.arg(QString::fromUtf8(res.out ? res.out : "")));
|
||||||
|
check(rc == 0 && res.nattach == 1,
|
||||||
|
QStringLiteral("and hands it over as a file for the user"));
|
||||||
|
QFile f(core.workspace() + QStringLiteral("/selftest.pdf"));
|
||||||
|
const bool opened = f.open(QIODevice::ReadOnly);
|
||||||
|
const QByteArray head = opened ? f.read(5) : QByteArray();
|
||||||
|
check(head == QByteArrayLiteral("%PDF-"),
|
||||||
|
QStringLiteral("the bytes are a PDF (%1)")
|
||||||
|
.arg(QString::fromLatin1(head)));
|
||||||
|
cst_tool_result_free(&res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* new sessions: the engine moves, the view empties, the store grows */
|
/* new sessions: the engine moves, the view empties, the store grows */
|
||||||
const int before = core.sessions().size();
|
const int before = core.sessions().size();
|
||||||
clickMouse(win, QStringLiteral("newSessionButton"));
|
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define CST_VERSION_MAJOR 0
|
#define CST_VERSION_MAJOR 0
|
||||||
#define CST_VERSION_MINOR 9
|
#define CST_VERSION_MINOR 9
|
||||||
#define CST_VERSION_PATCH 6
|
#define CST_VERSION_PATCH 7
|
||||||
|
|
||||||
#define CST_VERSION_STR_(x) #x
|
#define CST_VERSION_STR_(x) #x
|
||||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||||
|
|||||||
+50
-20
@@ -444,38 +444,68 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
|||||||
FILE *fp = fopen(p, "rb");
|
FILE *fp = fopen(p, "rb");
|
||||||
if (!fp)
|
if (!fp)
|
||||||
return 0;
|
return 0;
|
||||||
size_t cap = 1 << 16;
|
/* Reading a transcript must stay cheap: the session list asks for this for
|
||||||
char *line = malloc(cap);
|
* every stored session, on the GUI thread, every time the store changes.
|
||||||
int have_first = 0;
|
* So a line is only ever *parsed* while the first user message is still
|
||||||
while (line && fgets(line, (int)cap, fp)) {
|
* missing and only within the first kFirstUserBytes; everything else is
|
||||||
size_t n = strlen(line);
|
* counted by looking for newlines, which costs a memchr per block instead
|
||||||
if (n == cap - 1 && line[n - 1] != '\n') {
|
* of a cJSON tree per line. */
|
||||||
/* very long line: skip the rest, it is not the first user line */
|
enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 };
|
||||||
int ch;
|
char *buf = malloc(kBlock);
|
||||||
while ((ch = fgetc(fp)) != EOF && ch != '\n')
|
if (!buf) {
|
||||||
;
|
fclose(fp);
|
||||||
info->messages++;
|
return 0;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (n <= 1)
|
size_t have = 0; /* bytes in buf that belong to the current line */
|
||||||
continue;
|
size_t seen = 0; /* bytes already consumed by finished lines */
|
||||||
|
int slice = 0; /* the current line did not fit: its tail is dropped */
|
||||||
|
int want_first = 1; /* still looking for the first user message */
|
||||||
|
for (;;) {
|
||||||
|
size_t got = fread(buf + have, 1, kBlock - have, fp);
|
||||||
|
size_t total = have + got;
|
||||||
|
size_t at = 0;
|
||||||
|
for (;;) {
|
||||||
|
char *nl = memchr(buf + at, '\n', total - at);
|
||||||
|
if (!nl)
|
||||||
|
break;
|
||||||
|
const size_t len = (size_t)(nl - (buf + at));
|
||||||
|
if (len > 0)
|
||||||
info->messages++;
|
info->messages++;
|
||||||
if (have_first)
|
if (!slice && len > 0 && want_first && seen < kFirstUserBytes) {
|
||||||
continue;
|
*nl = 0; /* the parse reads a C string; the newline is ours */
|
||||||
cJSON *o = cJSON_Parse(line);
|
cJSON *o = cJSON_Parse(buf + at);
|
||||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
||||||
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
||||||
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
||||||
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
||||||
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
||||||
cJSON_IsString(c) && c->valuestring[0]) {
|
cJSON_IsString(c) && c->valuestring[0]) {
|
||||||
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
||||||
have_first = 1;
|
want_first = 0;
|
||||||
}
|
}
|
||||||
cJSON_Delete(o);
|
cJSON_Delete(o);
|
||||||
}
|
}
|
||||||
free(line);
|
seen += len + 1;
|
||||||
|
at += len + 1;
|
||||||
|
slice = 0;
|
||||||
|
}
|
||||||
|
have = total - at;
|
||||||
|
if (got == 0)
|
||||||
|
break; /* EOF */
|
||||||
|
if (have == kBlock) {
|
||||||
|
/* a line longer than one block: count it, drop its tail, keep going */
|
||||||
|
info->messages++;
|
||||||
|
seen += have;
|
||||||
|
have = 0;
|
||||||
|
slice = 1;
|
||||||
|
} else if (have > 0) {
|
||||||
|
memmove(buf, buf + at, have);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (have > 0 && !slice)
|
||||||
|
info->messages++; /* the last line, without a newline */
|
||||||
|
free(buf);
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,12 @@ static const cst_tool_def_t TOOLS[] = {
|
|||||||
"Search earlier and current sessions for text.",
|
"Search earlier and current sessions for text.",
|
||||||
{{"query", S, 1, "text to look for"},
|
{{"query", S, 1, "text to look for"},
|
||||||
{"max_matches", I, 0, "stop after this many matches (default 20)"},
|
{"max_matches", I, 0, "stop after this many matches (default 20)"},
|
||||||
{"ignore_case", B, 0, "case-insensitive (default true)"}},
|
{"ignore_case", B, 0, "case-insensitive (default true)"},
|
||||||
|
{"max_bytes", I, 0,
|
||||||
|
"stop after this many bytes of transcripts (default 32 MB, newest "
|
||||||
|
"sessions first); 0 or unset means the default"},
|
||||||
|
{"max_seconds", I, 0,
|
||||||
|
"stop after this many seconds of searching (default 5)"}},
|
||||||
CST_TP_READ, 0, cst_tool_session_search, NULL, NO_DYN, NO_META},
|
CST_TP_READ, 0, cst_tool_session_search, NULL, NO_DYN, NO_META},
|
||||||
{"device_info",
|
{"device_info",
|
||||||
"Where this agent is running: system and CPU count, workspace, sandbox, "
|
"Where this agent is running: system and CPU count, workspace, sandbox, "
|
||||||
|
|||||||
@@ -83,12 +83,30 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|||||||
if (want > 100)
|
if (want > 100)
|
||||||
want = 100;
|
want = 100;
|
||||||
int icase = tool_arg_bool(c, "ignore_case", 1);
|
int icase = tool_arg_bool(c, "ignore_case", 1);
|
||||||
|
/* A store grows without bound and this walks all of it, so it gets a
|
||||||
|
* budget: the walk stops when it is spent and says so, instead of making a
|
||||||
|
* turn sit for minutes on a workspace with a few hundred sessions. */
|
||||||
|
long budget_bytes = tool_arg_long(c, "max_bytes", 32L << 20);
|
||||||
|
if (budget_bytes <= 0)
|
||||||
|
budget_bytes = 32L << 20;
|
||||||
|
long budget_ms = tool_arg_long(c, "max_seconds", 5);
|
||||||
|
if (budget_ms <= 0)
|
||||||
|
budget_ms = 5;
|
||||||
|
budget_ms *= 1000;
|
||||||
|
const double t_start = os_now_ms();
|
||||||
|
long scanned_bytes = 0;
|
||||||
|
int cut = 0; /* the budget stopped the walk */
|
||||||
|
|
||||||
static char ids[200][128];
|
static char ids[200][128];
|
||||||
int n = cst_agent_store_list(ctx->workspace_root, ids, 200);
|
int n = cst_agent_store_list(ctx->workspace_root, ids, 200);
|
||||||
cst_buf_t hits = {0};
|
cst_buf_t hits = {0};
|
||||||
int matches = 0, in_sessions = 0, scanned_sessions = 0;
|
int matches = 0, in_sessions = 0, scanned_sessions = 0;
|
||||||
for (int i = 0; i < n && matches < (int)want; i++) {
|
for (int i = 0; i < n && matches < (int)want; i++) {
|
||||||
|
if (scanned_bytes >= budget_bytes ||
|
||||||
|
os_now_ms() - t_start > (double)budget_ms) {
|
||||||
|
cut = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
char path[OS_PATH_MAX];
|
char path[OS_PATH_MAX];
|
||||||
if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path,
|
if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path,
|
||||||
sizeof(path)) != 0)
|
sizeof(path)) != 0)
|
||||||
@@ -106,8 +124,24 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|||||||
ssize_t got;
|
ssize_t got;
|
||||||
while ((got = getline(&line, &cap, fp)) > 0) {
|
while ((got = getline(&line, &cap, fp)) > 0) {
|
||||||
lineno++;
|
lineno++;
|
||||||
if (got <= 1)
|
scanned_bytes += got;
|
||||||
|
/* A tool output line can be megabytes (a whole file read back): such a
|
||||||
|
* line is not scanned -- one cJSON tree per block of it would be the
|
||||||
|
* slowest thing in the turn. */
|
||||||
|
if (got <= 1 || got > (1 << 16)) {
|
||||||
|
if (scanned_bytes >= budget_bytes) {
|
||||||
|
cut = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
/* the clock is asked every so often: a clock_gettime per line would be
|
||||||
|
* the next slowest thing after the parse */
|
||||||
|
if ((lineno & 0xFF) == 0 && ((scanned_bytes >= budget_bytes ||
|
||||||
|
os_now_ms() - t_start > (double)budget_ms))) {
|
||||||
|
cut = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
cJSON *o = cJSON_Parse(line);
|
cJSON *o = cJSON_Parse(line);
|
||||||
if (!o)
|
if (!o)
|
||||||
continue;
|
continue;
|
||||||
@@ -159,10 +193,14 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|||||||
if (matches == 0) {
|
if (matches == 0) {
|
||||||
cst_buf_free(&hits);
|
cst_buf_free(&hits);
|
||||||
return tool_set_resultf(r, 0,
|
return tool_set_resultf(r, 0,
|
||||||
"no match for \"%.80s\" in %d stored session%s "
|
"no match for \"%.80s\" in %d of %d stored session%s"
|
||||||
"(the store holds %d)\n",
|
" (%.1f MB scanned%s)\n",
|
||||||
query, scanned_sessions,
|
query, scanned_sessions, n,
|
||||||
scanned_sessions == 1 ? "" : "s", n);
|
scanned_sessions == 1 ? "" : "s",
|
||||||
|
(double)scanned_bytes / (1024.0 * 1024.0),
|
||||||
|
cut ? "; the budget stopped the walk -- raise "
|
||||||
|
"max_bytes or max_seconds"
|
||||||
|
: "");
|
||||||
}
|
}
|
||||||
cst_buf_t out = {0};
|
cst_buf_t out = {0};
|
||||||
cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n",
|
cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n",
|
||||||
@@ -172,6 +210,13 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|||||||
if (matches >= (int)want)
|
if (matches >= (int)want)
|
||||||
cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the "
|
cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the "
|
||||||
"query)\n");
|
"query)\n");
|
||||||
|
if (cut)
|
||||||
|
cst_buf_printf(&out,
|
||||||
|
"\n(the walk stopped after %.1f MB / %.1f s of a %d MB store: "
|
||||||
|
"older sessions were not looked at -- raise max_bytes or "
|
||||||
|
"max_seconds to go further)\n",
|
||||||
|
(double)scanned_bytes / (1024.0 * 1024.0),
|
||||||
|
(os_now_ms() - t_start) / 1000.0, n);
|
||||||
cst_buf_free(&hits);
|
cst_buf_free(&hits);
|
||||||
return tool_take_buf(r, &out, 0);
|
return tool_take_buf(r, &out, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user