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:
WHC
2026-10-11 16:34:47 +08:00
parent 84f9585588
commit 60cbce3c5c
26 changed files with 2082 additions and 148 deletions
+150 -12
View File
@@ -344,16 +344,18 @@ static char *read_whole(const char *path, size_t *out_len, size_t max) {
}
/* FNV over the bytes, the name and the clock: short, stable, unique enough
* inside one session. */
static void make_id(const char *name, const char *data, size_t n, char *out,
size_t out_sz) {
* inside one session. The prefix says what the name is for: "img" for what a
* model is shown, "art" for what a person is handed. */
static void make_id(const char *prefix, const char *name, const char *data,
size_t n, char *out, size_t out_sz) {
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
h = cst_fnv1a64(h, name, strlen(name));
h = cst_fnv1a64(h, &n, sizeof(n));
double now = os_now_ms();
h = cst_fnv1a64(h, &now, sizeof(now));
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
snprintf(out, out_sz, "%s-%08x", prefix && prefix[0] ? prefix : "img",
(unsigned)(h & 0xFFFFFFFFu));
}
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
@@ -416,11 +418,15 @@ static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
return copy;
}
/* Write one payload whose kind is already known. */
/* Write one payload whose kind is already known. id_prefix names it ("img" /
* "art"), for_user marks it as the person's rather than the model's, and
* ext_override is the stored file's extension when the caller knows better
* than the mime does. */
static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *mime,
int pages, const char *origin, char *err,
size_t err_sz) {
int pages, const char *origin, int for_user,
const char *id_prefix, const char *ext_override,
char *err, size_t err_sz) {
if (!name || !name[0])
name = "image";
char dir[OS_PATH_MAX];
@@ -433,12 +439,14 @@ static cst_attachment_t *store(const char *session_path, const char *name,
snprintf(err, err_sz, "out of memory");
return NULL;
}
make_id(name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
make_id(id_prefix, name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id,
ext_override && ext_override[0] ? ext_override : mime_of_ext(mime));
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
snprintf(a->mime, sizeof(a->mime), "%s", mime);
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto");
a->for_user = for_user ? 1 : 0;
a->bytes = (long long)n;
a->pages = pages;
if (cst_attach_is_image(a))
@@ -494,7 +502,8 @@ cst_attachment_t *cst_attach_import_bytes(const char *session_path,
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
return store(session_path, name, data, n, mime, 0, "user", err, err_sz);
return store(session_path, name, data, n, mime, 0, "user", 0, "img", NULL,
err, err_sz);
}
cst_attachment_t *cst_attach_import_file(const char *session_path,
@@ -526,11 +535,135 @@ cst_attachment_t *cst_attach_import_file(const char *session_path,
if (!payload)
return NULL;
cst_attachment_t *a =
store(session_path, leaf, payload, pn, mime, pages, "user", err, err_sz);
store(session_path, leaf, payload, pn, mime, pages, "user", 0, "img",
NULL, err, err_sz);
free(payload);
return a;
}
/* The mime of a delivered file: its extension decides (the content is not
* sniffed -- a .zip is not a picture), and anything unknown is a plain blob
* the OS still opens by name. */
static const char *mime_from_name(const char *name) {
static const struct {
const char *ext, *mime;
} T[] = {
{"md", "text/markdown"}, {"markdown", "text/markdown"},
{"txt", "text/plain"}, {"log", "text/plain"},
{"json", "application/json"}, {"csv", "text/csv"},
{"html", "text/html"}, {"htm", "text/html"},
{"xml", "application/xml"}, {"yaml", "application/yaml"},
{"yml", "application/yaml"}, {"toml", "application/toml"},
{"pdf", "application/pdf"}, {"zip", "application/zip"},
{"gz", "application/gzip"}, {"tgz", "application/gzip"},
{"tar", "application/x-tar"}, {"sh", "text/x-shellscript"},
{"py", "text/x-python"}, {"c", "text/x-c"},
{"h", "text/x-c"}, {"cpp", "text/x-c++"},
{"java", "text/x-java"}, {"kt", "text/x-kotlin"},
{"js", "text/javascript"}, {"ts", "text/typescript"},
{"go", "text/x-go"}, {"rs", "text/x-rust"},
{"rb", "text/x-ruby"}, {"qml", "text/x-qml"},
{"css", "text/css"}, {"sql", "text/x-sql"},
{"diff", "text/x-diff"}, {"patch", "text/x-diff"},
{"png", "image/png"}, {"jpg", "image/jpeg"},
{"jpeg", "image/jpeg"}, {"gif", "image/gif"},
{"webp", "image/webp"}, {"bmp", "image/bmp"},
{"svg", "image/svg+xml"}, {"ico", "image/x-icon"},
{"mp3", "audio/mpeg"}, {"m4a", "audio/mp4"},
{"ogg", "audio/ogg"}, {"wav", "audio/wav"},
{"mp4", "video/mp4"}, {"webm", "video/webm"},
{"apk", "application/vnd.android.package-archive"},
{"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
{"xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
{"pptx",
"application/vnd.openxmlformats-officedocument.presentationml."
"presentation"},
};
const char *dot = name ? strrchr(name, '.') : NULL;
if (!dot || !dot[1])
return "application/octet-stream";
const char *e = dot + 1;
size_t el = strlen(e);
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
if (strlen(T[i].ext) != el)
continue;
size_t k = 0;
for (; k < el; k++) {
char c = e[k];
if (c >= 'A' && c <= 'Z')
c = (char)(c - 'A' + 'a');
if (c != T[i].ext[k])
break;
}
if (k == el)
return T[i].mime;
}
return "application/octet-stream";
}
/* The stored file's extension: the name's own when it is plain ([a-z0-9], at
* most 8 characters -- a stored name has to stay portable), else whatever the
* mime maps to. */
static void ext_for_name(const char *name, const char *mime, char *out,
size_t out_sz) {
size_t k = 0;
const char *dot = name ? strrchr(name, '.') : NULL;
if (dot && dot[1] && out_sz > 1) {
for (const char *p = dot + 1; *p && k < 8 && k + 1 < out_sz; p++) {
char c = *p;
if (c >= 'A' && c <= 'Z')
c = (char)(c - 'A' + 'a');
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
break;
out[k++] = c;
}
}
out[k] = 0;
if (!k)
snprintf(out, out_sz, "%s", mime_of_ext(mime));
}
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
const char *src_path,
const char *title, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the file in");
return NULL;
}
if (!src_path || !src_path[0]) {
snprintf(err, err_sz, "no file to deliver");
return NULL;
}
size_t n = 0;
char *data = read_whole(src_path, &n, CST_ARTIFACT_MAX_BYTES);
if (!data) {
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
src_path, (int)(CST_ARTIFACT_MAX_BYTES >> 20));
return NULL;
}
const char *leaf = src_path;
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
const char *name = (title && title[0]) ? title : leaf;
char mime[64], ext[16];
snprintf(mime, sizeof(mime), "%s", mime_from_name(name));
ext_for_name(name, mime, ext, sizeof(ext));
cst_attachment_t *a = store(session_path, name, data, n, mime, 0, "tool", 1,
"art", ext, err, err_sz);
free(data);
return a;
}
/* Images are opaque and travel as image parts; everything else is text the
* store holds and the request inlines. */
int cst_attach_is_image(const cst_attachment_t *a) {
@@ -538,7 +671,8 @@ int cst_attach_is_image(const cst_attachment_t *a) {
}
int cst_attach_is_text(const cst_attachment_t *a) {
return a && !cst_attach_is_image(a);
/* A delivered file is the person's: it is never inlined into a request. */
return a && !a->for_user && !cst_attach_is_image(a);
}
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
@@ -688,6 +822,8 @@ char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(o, "origin", a->origin);
if (a->for_user)
cJSON_AddNumberToObject(o, "for_user", 1);
cJSON_AddItemToArray(arr, o);
}
char *s = cJSON_PrintUnformatted(arr);
@@ -740,6 +876,8 @@ cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out)
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
snprintf(a->origin, sizeof(a->origin), "%s",
cJSON_IsString(v) ? v->valuestring : "");
v = cJSON_GetObjectItemCaseSensitive(it, "for_user");
a->for_user = cJSON_IsNumber(v) ? v->valueint : 0;
k++;
}
if (k == 0) {
+22 -6
View File
@@ -296,10 +296,12 @@ static cJSON *document_part(const cst_llm_request_t *req,
}
/* Append every attachment of m: images as image parts (or a note saying why
* not), documents inline as text. */
static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget,
size_t *doc_budget, const char *img_off) {
* not), documents inline as text. Returns how many parts were added: a
* delivered file (for_user) adds none. */
static int parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget,
size_t *doc_budget, const char *img_off) {
int added = 0;
int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) {
char note[160];
@@ -307,10 +309,15 @@ static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
"[only the first %d of %d attachments are sent]",
CST_ATTACH_MAX_COUNT, n);
parts_add_text(parts, cJSON_CreateString(note));
added++;
n = CST_ATTACH_MAX_COUNT;
}
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[i];
/* A delivered file belongs to the person, not to the model: no part, and
* not even a "not sent" note -- it was never meant to be in the request. */
if (a->for_user)
continue;
int is_img = cst_attach_is_image(a);
char why[256];
cJSON *p = NULL;
@@ -322,13 +329,16 @@ static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
p = document_part(req, a, doc_budget, why, sizeof(why));
if (p) {
cJSON_AddItemToArray(parts, p);
added++;
continue;
}
char note[512];
snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
is_img ? "image" : "document", a->name, why);
parts_add_text(parts, cJSON_CreateString(note));
added++;
}
return added;
}
/* Images a tool result produced are deferred: an assistant tool_call must be
@@ -345,8 +355,14 @@ static void defer_attachments(const cst_llm_request_t *req,
cJSON_AddArrayToObject(p, "content");
*pending = p;
}
parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget, doc_budget, img_off);
cJSON *content = cJSON_GetObjectItemCaseSensitive(p, "content");
parts_add_attachments(req, content, m, budget, doc_budget, img_off);
if (cJSON_GetArraySize(content) == 0) {
/* Nothing to carry (a tool result that only delivered a file): an empty
* user message is noise, and some providers refuse it. */
cJSON_Delete(p);
*pending = NULL;
}
}
/* m's text plus one line per image it would have carried (malloc'd); a
+11 -19
View File
@@ -280,26 +280,18 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
cJSON_Delete(mo);
}
if (msg->attach && msg->nattach > 0) {
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < msg->nattach; i++) {
const cst_attachment_t *a = &msg->attach[i];
cJSON *it = cJSON_CreateObject();
cJSON_AddStringToObject(it, "id", a->id);
cJSON_AddStringToObject(it, "name", a->name);
cJSON_AddStringToObject(it, "mime", a->mime);
cJSON_AddStringToObject(it, "rel", a->rel);
cJSON_AddNumberToObject(it, "bytes", (double)a->bytes);
if (a->width > 0 && a->height > 0) {
cJSON_AddNumberToObject(it, "width", a->width);
cJSON_AddNumberToObject(it, "height", a->height);
}
if (a->detail[0])
cJSON_AddStringToObject(it, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(it, "origin", a->origin);
cJSON_AddItemToArray(arr, it);
/* One writer for every attachment (cst_attachments_to_json): a field it
* carries must not have to be remembered here too -- forgetting
* `for_user` would put a delivered file back in front of the model. */
char *json = cst_attachments_to_json(msg->attach, msg->nattach);
if (json) {
cJSON *arr = cJSON_Parse(json);
free(json);
if (cJSON_IsArray(arr))
cJSON_AddItemToObject(o, "attachments", arr);
else
cJSON_Delete(arr);
}
cJSON_AddItemToObject(o, "attachments", arr);
}
char *txt = cJSON_PrintUnformatted(o);
cJSON_Delete(o);
+50
View File
@@ -291,6 +291,56 @@ int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
return rc;
}
/* --- deliver_file -------------------------------------------------------- */
/* The file IS the answer: copy it into the session store and hand it to the
* person as a card the front end can open or share. It is marked for_user, so
* the model never sees the copy again -- the result line has to say what was
* delivered. */
int cst_tool_deliver_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
const char *path = tool_arg(c, "path");
if (!path || !path[0])
return tool_set_result(r, "error: missing path", 1);
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : NULL;
if (!session || !session[0])
return tool_set_result(r, "error: no session to store the file in", 1);
char full[OS_PATH_MAX];
if (resolve(ctx, path, full, sizeof(full)) != 0)
return tool_set_resultf(r, 1,
"error: path outside workspace or missing parent: "
"%.200s",
path);
if (is_dir(full))
return tool_set_resultf(r, 1, "error: %.200s is a directory", path);
char err[256];
cst_attachment_t *a =
cst_attach_import_artifact(session, full, tool_arg(c, "title"), err,
sizeof(err));
if (!a)
return tool_set_resultf(r, 1, "error: cannot deliver %.200s: %.200s", path,
err);
char human[32];
if (a->bytes >= 1024 * 1024)
snprintf(human, sizeof(human), "%.1f MB",
(double)a->bytes / (1024.0 * 1024.0));
else if (a->bytes >= 1024)
snprintf(human, sizeof(human), "%.0f KB", (double)a->bytes / 1024.0);
else
snprintf(human, sizeof(human), "%lld B", a->bytes);
cst_buf_t out = {0};
cst_buf_printf(&out, "delivered %.120s (%s, %s)\n", a->name, a->mime, human);
cst_buf_puts(&out,
"(the user sees it as a file card they can open or share; "
"you will not be shown its bytes again)\n");
/* After tool_take_buf: it clears the result, and view_image does the same. */
int rc = tool_take_buf(r, &out, 0);
r->attach = a; /* one entry; freed as a block by cst_tool_result_free */
r->nattach = 1;
return rc;
}
/* --- edit_file ----------------------------------------------------------- */
static size_t count_occ(const char *hay, size_t hn, const char *needle,
+11
View File
@@ -67,6 +67,17 @@ static const cst_tool_def_t TOOLS[] = {
{{"path", S, 1, "image file path relative to the workspace"},
{"detail", S, 0, "low or high (provider hint; default auto)"}},
CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN, NO_META},
{"deliver_file",
"Hand a finished file to the person: it is copied into the session store "
"and shown in the transcript as a card they can open or share. Use it "
"whenever the answer is a file -- a report, an export, a patch, a "
"picture, a build. You are not shown the copy again, so say what it "
"contains in your answer.",
"Deliver a workspace file to the user (a file card in the transcript, "
"openable and shareable from there).",
{{"path", S, 1, "file path relative to the workspace"},
{"title", S, 0, "display name (default: the file name)"}},
CST_TP_READ, 0, cst_tool_deliver_file, NULL, NO_DYN, NO_META},
{"run_shell",
"Run a non-interactive shell command in the workspace root: builds, "
"tests, git, scripts, computations, data processing, other CLIs "
+2
View File
@@ -57,6 +57,8 @@ int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_deliver_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_preview_write_file(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out);