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:
+61
-31
@@ -444,38 +444,68 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
||||
FILE *fp = fopen(p, "rb");
|
||||
if (!fp)
|
||||
return 0;
|
||||
size_t cap = 1 << 16;
|
||||
char *line = malloc(cap);
|
||||
int have_first = 0;
|
||||
while (line && fgets(line, (int)cap, fp)) {
|
||||
size_t n = strlen(line);
|
||||
if (n == cap - 1 && line[n - 1] != '\n') {
|
||||
/* very long line: skip the rest, it is not the first user line */
|
||||
int ch;
|
||||
while ((ch = fgetc(fp)) != EOF && ch != '\n')
|
||||
;
|
||||
info->messages++;
|
||||
continue;
|
||||
}
|
||||
if (n <= 1)
|
||||
continue;
|
||||
info->messages++;
|
||||
if (have_first)
|
||||
continue;
|
||||
cJSON *o = cJSON_Parse(line);
|
||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
||||
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
||||
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
||||
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
||||
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
||||
cJSON_IsString(c) && c->valuestring[0]) {
|
||||
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
||||
have_first = 1;
|
||||
}
|
||||
cJSON_Delete(o);
|
||||
/* Reading a transcript must stay cheap: the session list asks for this for
|
||||
* every stored session, on the GUI thread, every time the store changes.
|
||||
* So a line is only ever *parsed* while the first user message is still
|
||||
* missing and only within the first kFirstUserBytes; everything else is
|
||||
* counted by looking for newlines, which costs a memchr per block instead
|
||||
* of a cJSON tree per line. */
|
||||
enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 };
|
||||
char *buf = malloc(kBlock);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return 0;
|
||||
}
|
||||
free(line);
|
||||
size_t have = 0; /* bytes in buf that belong to the current line */
|
||||
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++;
|
||||
if (!slice && len > 0 && want_first && seen < kFirstUserBytes) {
|
||||
*nl = 0; /* the parse reads a C string; the newline is ours */
|
||||
cJSON *o = cJSON_Parse(buf + at);
|
||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
||||
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
||||
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
||||
const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
||||
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
||||
cJSON_IsString(c) && c->valuestring[0]) {
|
||||
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
||||
want_first = 0;
|
||||
}
|
||||
cJSON_Delete(o);
|
||||
}
|
||||
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);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,12 @@ static const cst_tool_def_t TOOLS[] = {
|
||||
"Search earlier and current sessions for text.",
|
||||
{{"query", S, 1, "text to look for"},
|
||||
{"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},
|
||||
{"device_info",
|
||||
"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)
|
||||
want = 100;
|
||||
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];
|
||||
int n = cst_agent_store_list(ctx->workspace_root, ids, 200);
|
||||
cst_buf_t hits = {0};
|
||||
int matches = 0, in_sessions = 0, scanned_sessions = 0;
|
||||
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];
|
||||
if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path,
|
||||
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;
|
||||
while ((got = getline(&line, &cap, fp)) > 0) {
|
||||
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;
|
||||
}
|
||||
/* 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);
|
||||
if (!o)
|
||||
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) {
|
||||
cst_buf_free(&hits);
|
||||
return tool_set_resultf(r, 0,
|
||||
"no match for \"%.80s\" in %d stored session%s "
|
||||
"(the store holds %d)\n",
|
||||
query, scanned_sessions,
|
||||
scanned_sessions == 1 ? "" : "s", n);
|
||||
"no match for \"%.80s\" in %d of %d stored session%s"
|
||||
" (%.1f MB scanned%s)\n",
|
||||
query, scanned_sessions, 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_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)
|
||||
cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the "
|
||||
"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);
|
||||
return tool_take_buf(r, &out, 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user