v0.9.2: files the agent delivers come back as cards, and the saved key is used
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.
deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
the model; cst_attach_import_artifact() stores any bytes up to
CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
skips for_user entries without even a "not sent" note, and a turn that only
delivered a file no longer invents an empty user message. tests/test_artifacts.c
pins that down end to end, including a session-file round trip.
The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
entry is a file card (extension badge, name, type and size, Open, Share);
a picture stays a thumbnail and opens on tap. Both shapes are read, so
sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
the session's own messages carries it, and hand the display name to the
platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
resolveActivity() checked, read permission granted) and shares through the
share sheet; a desktop uses QDesktopServices / the clipboard.
CST_APP_NO_OPEN=1 makes --uitest run without opening anything.
Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
`for_user`: the file survived the file, but came back as an attachment --
no card, and put back in front of the model. It now uses the one writer
(cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
card that is still running -- and the engine reports the result *before*
the files, so the card was already complete and the files were dropped.
A live turn therefore showed no card while a replay of the same session
did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
never drawn. The card now carries the reason ("no app on the phone opens
report.md").
Also
- The saved provider key reaches the engine on every start: AgentCore::open()
was handed an empty key and the engine never resolves credentials itself
(only the CLI did), so every start asked for it again. It resolves the same
order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
and keeps failing on the NX789J -- the build() returns null and Qt clears
the exception before C++ can read it, so the call has to be wrapped in Java
next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
refused), the live order of result-then-files, and the startup row count.
Version 0.9.2.
This commit is contained in:
+118
-16
@@ -16,6 +16,7 @@
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "ToolProvider.h" /* openLocalFile / shareLocalFile */
|
||||
#include "cst_agent.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_models.h"
|
||||
@@ -113,6 +114,22 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
||||
snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", m);
|
||||
}
|
||||
}
|
||||
/* The window is started without --api-key, and the engine's config never
|
||||
* resolves credentials on its own: without this the key the user saved for
|
||||
* the provider would be in the file but not in the turn, and every start
|
||||
* would ask for it again. Same order as the CLI (main.c): a key passed to
|
||||
* open() > the saved one > the provider's env var > CST_API_KEY. */
|
||||
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
|
||||
char key[512] = "";
|
||||
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
|
||||
const char *env = getenv("CST_API_KEY");
|
||||
if (env && env[0])
|
||||
snprintf(key, sizeof(key), "%s", env);
|
||||
}
|
||||
if (key[0])
|
||||
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s", key);
|
||||
memset(key, 0, sizeof(key)); /* never let it outlive the call */
|
||||
}
|
||||
/* Sessions live in the workspace's store -- the same files the CLI and the
|
||||
* WebUI use. Without this the engine would open session.jsonl in the
|
||||
* process's working directory, next to a session list that stays empty. */
|
||||
@@ -334,28 +351,81 @@ QString field(const char *s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/* The images of one message or tool result, one line per picture:
|
||||
/* The files of one message or tool result, one line each:
|
||||
*
|
||||
* <stored name>|<name>|<origin>|<w>x<h>
|
||||
* <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
|
||||
*
|
||||
* A list role is not an option: a ListModel keeps what fits in a QVariant of
|
||||
* a simple type -- a list reads back as undefined there, and setProperty drops
|
||||
* the role -- so the rows carry this text and ImageStrip.qml decodes it. A
|
||||
* document (PDF, text) is not a picture and stays out of it. */
|
||||
QString imagesText(const cst_attachment_t *list, int n) {
|
||||
* kind is "img" for a picture (a thumbnail the view can show) or "file" for a
|
||||
* file the agent delivered (a card with open and share). A list role is not an
|
||||
* option: a ListModel keeps what fits in a QVariant of a simple type -- a list
|
||||
* reads back as undefined there, and setProperty drops the role -- so the rows
|
||||
* carry this text and ImageStrip.qml decodes it. A document the user attached
|
||||
* (PDF, text) is named in the message text and stays out of it, and so does
|
||||
* anything older sessions wrote. */
|
||||
QString filesText(const cst_attachment_t *list, int n) {
|
||||
QStringList lines;
|
||||
for (int i = 0; list && i < n; i++) {
|
||||
const cst_attachment_t *a = &list[i];
|
||||
if (!cst_attach_is_image(a))
|
||||
const bool img = cst_attach_is_image(a) != 0;
|
||||
if (!img && !a->for_user)
|
||||
continue;
|
||||
lines << QStringLiteral("%1|%2|%3|%4x%5")
|
||||
.arg(field(a->rel), field(a->name), field(a->origin))
|
||||
.arg(a->width)
|
||||
.arg(a->height);
|
||||
QString line = QStringLiteral("%1|%2|%3|%4|%5|%6|%7x%8");
|
||||
line = line.arg(img ? QStringLiteral("img") : QStringLiteral("file"));
|
||||
line = line.arg(field(a->rel));
|
||||
line = line.arg(field(a->name));
|
||||
line = line.arg(field(a->origin));
|
||||
line = line.arg(field(a->mime));
|
||||
line = line.arg(a->bytes);
|
||||
line = line.arg(a->width);
|
||||
line = line.arg(a->height);
|
||||
lines << line;
|
||||
}
|
||||
return lines.join(QLatin1Char('\n'));
|
||||
}
|
||||
|
||||
/* A stored file of this session, named the way a file card names it: its full
|
||||
* path, or empty when the name is not one this session's store owns. A card
|
||||
* hands back a name it got from the engine (filesText), so a name no message
|
||||
* carries is not a file of this session at all -- never open it. `title`, when
|
||||
* asked for, gets the display name the person knows the file by. */
|
||||
QString artifactPathOf(cst_agent_t *agent, const QString &rel, QString *title) {
|
||||
if (title)
|
||||
title->clear();
|
||||
if (!agent)
|
||||
return QString();
|
||||
const QByteArray r = rel.trimmed().toUtf8();
|
||||
if (r.isEmpty() || !cst_attach_rel_ok(r.constData()))
|
||||
return QString();
|
||||
cst_session_t *s = cst_agent_session(agent);
|
||||
if (!s)
|
||||
return QString();
|
||||
bool mine = false;
|
||||
cst_session_lock(s);
|
||||
const int n = cst_session_count(s);
|
||||
for (int i = 0; !mine && i < n; i++) {
|
||||
const cst_message_t *m = cst_session_get(s, i);
|
||||
for (int a = 0; m && a < m->nattach; a++) {
|
||||
if (strcmp(m->attach[a].rel, r.constData()) != 0)
|
||||
continue;
|
||||
mine = true;
|
||||
if (title)
|
||||
*title = utf8(m->attach[a].name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
cst_session_unlock(s);
|
||||
if (!mine)
|
||||
return QString();
|
||||
cst_attachment_t a;
|
||||
memset(&a, 0, sizeof(a));
|
||||
snprintf(a.rel, sizeof(a.rel), "%s", r.constData());
|
||||
const char *session = cst_agent_session_path(agent);
|
||||
char full[4096] = "";
|
||||
if (!session || cst_attach_full_path(session, &a, full, sizeof(full)) != 0)
|
||||
return QString();
|
||||
return utf8(full);
|
||||
}
|
||||
|
||||
/* The open tool row this result belongs to, -1 when none. Rows are paired by
|
||||
* call id first: the same tool may run twice in a turn, and pairing by name
|
||||
* would hang the first card on "running" forever. Only a call without an id
|
||||
@@ -407,6 +477,38 @@ QString AgentCore::attachmentDir() const {
|
||||
return utf8(dir);
|
||||
}
|
||||
|
||||
/* A file card's "open": the file goes to whatever the system uses for its
|
||||
* type (Android ACTION_VIEW through the FileProvider, a desktop's own
|
||||
* application). "" when it went through. */
|
||||
QString AgentCore::openArtifact(const QString &rel) {
|
||||
QString title;
|
||||
const QString path = artifactPathOf(m_agent, rel, &title);
|
||||
if (path.isEmpty())
|
||||
return QStringLiteral("no such file in this session: %1").arg(rel);
|
||||
QString status;
|
||||
const QString err = app::openLocalFile(path, title, &status);
|
||||
if (!err.isEmpty())
|
||||
return err;
|
||||
if (!status.isEmpty())
|
||||
emit statusChanged(status);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* The same through the share sheet (a phone) or the clipboard (a desktop). */
|
||||
QString AgentCore::shareArtifact(const QString &rel) {
|
||||
QString title;
|
||||
const QString path = artifactPathOf(m_agent, rel, &title);
|
||||
if (path.isEmpty())
|
||||
return QStringLiteral("no such file in this session: %1").arg(rel);
|
||||
QString status;
|
||||
const QString err = app::shareLocalFile(path, title, &status);
|
||||
if (!err.isEmpty())
|
||||
return err;
|
||||
if (!status.isEmpty())
|
||||
emit statusChanged(status);
|
||||
return QString();
|
||||
}
|
||||
|
||||
QVariantList AgentCore::sessions() const {
|
||||
QVariantList out;
|
||||
if (m_workspace.isEmpty())
|
||||
@@ -457,14 +559,14 @@ QVariantList AgentCore::history() const {
|
||||
r["name"] = name;
|
||||
r["callId"] = callId;
|
||||
r["output"] = utf8(m->content);
|
||||
r["images"] = imagesText(m->attach, m->nattach);
|
||||
r["images"] = filesText(m->attach, m->nattach);
|
||||
r["state"] = "done";
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
} else {
|
||||
QVariantMap r = rows.at(at).toMap();
|
||||
r["output"] = utf8(m->content);
|
||||
r["images"] = imagesText(m->attach, m->nattach);
|
||||
r["images"] = filesText(m->attach, m->nattach);
|
||||
r["state"] = "done";
|
||||
r["complete"] = true;
|
||||
rows[at] = r;
|
||||
@@ -490,7 +592,7 @@ QVariantList AgentCore::history() const {
|
||||
if (!names.isEmpty())
|
||||
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
|
||||
r["text"] = text;
|
||||
r["images"] = imagesText(m->attach, m->nattach);
|
||||
r["images"] = filesText(m->attach, m->nattach);
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
continue;
|
||||
@@ -1027,7 +1129,7 @@ void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||
void AgentCore::hookToolImages(const ctl_tool_call_t *call,
|
||||
const cst_attachment_t *list, int n, void *ud) {
|
||||
AgentCore *self = static_cast<AgentCore *>(ud);
|
||||
const QString images = imagesText(list, n);
|
||||
const QString images = filesText(list, n);
|
||||
if (images.isEmpty())
|
||||
return; /* a call that made no picture needs no signal */
|
||||
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
||||
|
||||
Reference in New Issue
Block a user