@mono-agent/web 0.20.11 → 0.20.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/contracts.d.ts +75 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/effort-ladder.d.ts +97 -0
- package/dist/effort-ladder.d.ts.map +1 -0
- package/dist/effort-ladder.js +114 -0
- package/dist/effort-ladder.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/notification-client.d.ts +11 -2
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +1 -1
- package/dist/notification-client.js.map +1 -1
- package/dist/notification-ingress.d.ts +8 -1
- package/dist/notification-ingress.d.ts.map +1 -1
- package/dist/notification-ingress.js +28 -6
- package/dist/notification-ingress.js.map +1 -1
- package/dist/operator-client.d.ts +10 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +110 -2
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +52 -3
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +100 -12
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +531 -65
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +97 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +501 -33
- package/dist/store.js.map +1 -1
- package/package.json +5 -7
- package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
- package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
- package/webapp/dist/index.html +2 -2
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-BT463dRM.css +0 -1
- package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
package/dist/store.js
CHANGED
|
@@ -3,7 +3,7 @@ import { chmod, lstat, readdir, unlink } from "node:fs/promises";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
|
-
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
|
|
6
|
+
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, AGENT_LIVE_INPUT_MAX_MESSAGES, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseMonitorProjection, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
|
|
7
7
|
import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_MAX_TURN_TEXT_CHARACTERS, } from "./contracts.js";
|
|
8
8
|
import { WebConsoleError } from "./errors.js";
|
|
9
9
|
import { webPushPreview } from "./push-preview.js";
|
|
@@ -118,7 +118,7 @@ export function messageSearchMatchExpression(raw) {
|
|
|
118
118
|
export function escapeLikeTerm(raw) {
|
|
119
119
|
return raw.replaceAll(/[\\%_]/gu, (character) => `\\${character}`);
|
|
120
120
|
}
|
|
121
|
-
const WEB_STORAGE_SCHEMA_VERSION =
|
|
121
|
+
const WEB_STORAGE_SCHEMA_VERSION = 15;
|
|
122
122
|
const MAX_REVISIONS_PER_THREAD = 1_000;
|
|
123
123
|
export const WEB_THREAD_PAGE_MAX = 200;
|
|
124
124
|
export const WEB_MESSAGE_PAGE_MAX = 100;
|
|
@@ -133,6 +133,14 @@ export class WebStore {
|
|
|
133
133
|
database;
|
|
134
134
|
clock;
|
|
135
135
|
closed = false;
|
|
136
|
+
/**
|
|
137
|
+
* The agent PROCESS generation each live summary was built from, by source
|
|
138
|
+
* id. Deliberately not a column: it describes the process behind a source id
|
|
139
|
+
* right now, so a value read back from disk after a console restart would be
|
|
140
|
+
* a claim about a process nobody probed. `replaceAgents` is the only writer,
|
|
141
|
+
* and it drops ids discovery no longer reports.
|
|
142
|
+
*/
|
|
143
|
+
agentGenerations = new Map();
|
|
136
144
|
constructor(database, paths, clock) {
|
|
137
145
|
this.database = database;
|
|
138
146
|
this.paths = paths;
|
|
@@ -179,24 +187,77 @@ export class WebStore {
|
|
|
179
187
|
this.closed = true;
|
|
180
188
|
this.database.close();
|
|
181
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Persist the discovered agent list. Returns whether the change is worth
|
|
192
|
+
* telling clients about.
|
|
193
|
+
*
|
|
194
|
+
* `updatedAt` is a heartbeat timestamp that moves on every discovery poll, so
|
|
195
|
+
* comparing it made this return `true` roughly once every five seconds. At the
|
|
196
|
+
* time, `agents.changed` carried the whole agent list, every model, every model
|
|
197
|
+
* option and every provider. Measured against a 67-agent fleet that was 60 KB
|
|
198
|
+
* per event and 99.5% of all SSE traffic: ~42 MiB/hour to an idle console with
|
|
199
|
+
* nobody using it. The event is now a compact invalidation, but a heartbeat is
|
|
200
|
+
* still not a state change worth making every browser re-bootstrap for.
|
|
201
|
+
*
|
|
202
|
+
* So the two questions are separated. Any difference at all is still written,
|
|
203
|
+
* because the store should hold the freshest heartbeat; only a difference that
|
|
204
|
+
* survives normalizing `updatedAt` is broadcast. Nothing in the console reads
|
|
205
|
+
* an agent's `updatedAt` --- it stays on the wire for clients that want it, it
|
|
206
|
+
* just no longer triggers a fleet-sized frame on its own. `agentGeneration()`
|
|
207
|
+
* already excludes it for the same reason.
|
|
208
|
+
*/
|
|
182
209
|
replaceAgents(agents) {
|
|
183
210
|
const current = this.listAgents();
|
|
184
211
|
const currentById = new Map(current.map((agent) => [agent.sourceId, agent]));
|
|
185
212
|
const incomingIds = new Set(agents.map((agent) => agent.sourceId));
|
|
186
|
-
const
|
|
213
|
+
const departed = current.some((agent) => !incomingIds.has(agent.sourceId) && agent.status !== "offline");
|
|
214
|
+
const differs = (agent, ignoreHeartbeat) => {
|
|
187
215
|
const prior = currentById.get(agent.sourceId);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
216
|
+
if (prior === undefined)
|
|
217
|
+
return true;
|
|
218
|
+
// `pinned` is store-owned and never arrives from discovery; normalizing it
|
|
219
|
+
// keeps a locally pinned agent from looking like an incoming change.
|
|
220
|
+
const next = { ...agent, pinned: prior.pinned };
|
|
221
|
+
if (!ignoreHeartbeat)
|
|
222
|
+
return !isDeepStrictEqual(prior, next);
|
|
223
|
+
return !isDeepStrictEqual({ ...prior, updatedAt: "" }, { ...next, updatedAt: "" });
|
|
224
|
+
};
|
|
225
|
+
const changed = agents.some((agent) => differs(agent, false)) || departed;
|
|
226
|
+
const notable = agents.some((agent) => differs(agent, true)) || departed;
|
|
227
|
+
// After `current` is read (it carries the PREVIOUS generations) and after
|
|
228
|
+
// both comparisons, so a restart behind an otherwise identical summary
|
|
229
|
+
// still reads as a change and still reaches the browser.
|
|
230
|
+
//
|
|
231
|
+
// And only once the row it describes is actually on disk. Advancing before
|
|
232
|
+
// the transaction made the map a claim the database had not agreed to: a
|
|
233
|
+
// transaction that threw left the NEW generation stitched onto the OLD row,
|
|
234
|
+
// so the retry compared the new summary against a prior that already
|
|
235
|
+
// carried its generation, found only `updatedAt` different, and returned
|
|
236
|
+
// `notable === false`. The restart broadcast was then lost permanently ---
|
|
237
|
+
// not deferred --- and an open console stayed stale until it reconnected.
|
|
238
|
+
const adoptGenerations = () => {
|
|
239
|
+
this.agentGenerations.clear();
|
|
240
|
+
for (const agent of agents) {
|
|
241
|
+
if (agent.generation !== undefined)
|
|
242
|
+
this.agentGenerations.set(agent.sourceId, agent.generation);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
if (!changed) {
|
|
246
|
+
// Nothing to persist, so there is nothing for the adoption to outrun:
|
|
247
|
+
// every incoming generation already equals the one it is replacing, or
|
|
248
|
+
// `changed` would be true. The map is still swept so a departed agent
|
|
249
|
+
// does not leave one behind.
|
|
250
|
+
adoptGenerations();
|
|
191
251
|
return false;
|
|
252
|
+
}
|
|
192
253
|
this.transaction(() => {
|
|
193
254
|
this.database.prepare("UPDATE agents SET status = 'offline'").run();
|
|
194
255
|
const statement = this.database.prepare(`
|
|
195
256
|
INSERT INTO agents (
|
|
196
257
|
source_id, label, status, health, supports_attachments, models_json,
|
|
197
258
|
default_model, default_effort, efforts_json, model_options_json,
|
|
198
|
-
cron_read, cron_actions, ask_by_id, updated_at
|
|
199
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
259
|
+
providers_json, cron_read, cron_actions, ask_by_id, updated_at
|
|
260
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
200
261
|
ON CONFLICT(source_id) DO UPDATE SET
|
|
201
262
|
label = excluded.label,
|
|
202
263
|
status = excluded.status,
|
|
@@ -207,24 +268,31 @@ export class WebStore {
|
|
|
207
268
|
default_effort = excluded.default_effort,
|
|
208
269
|
efforts_json = excluded.efforts_json,
|
|
209
270
|
model_options_json = excluded.model_options_json,
|
|
271
|
+
providers_json = excluded.providers_json,
|
|
210
272
|
cron_read = excluded.cron_read,
|
|
211
273
|
cron_actions = excluded.cron_actions,
|
|
212
274
|
ask_by_id = excluded.ask_by_id,
|
|
213
275
|
updated_at = excluded.updated_at
|
|
214
276
|
`);
|
|
215
277
|
for (const agent of agents) {
|
|
216
|
-
statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
|
|
278
|
+
statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), stringifyOptional(agent.providers), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
|
|
217
279
|
}
|
|
218
280
|
});
|
|
219
|
-
|
|
281
|
+
adoptGenerations();
|
|
282
|
+
return notable;
|
|
220
283
|
}
|
|
221
284
|
listAgents() {
|
|
222
285
|
const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
|
|
223
|
-
return rows.map(mapAgent);
|
|
286
|
+
return rows.map((row) => this.withGeneration(mapAgent(row)));
|
|
224
287
|
}
|
|
225
288
|
getAgent(sourceId) {
|
|
226
289
|
const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?")).get(sourceId);
|
|
227
|
-
return row === undefined ? undefined : mapAgent(row);
|
|
290
|
+
return row === undefined ? undefined : this.withGeneration(mapAgent(row));
|
|
291
|
+
}
|
|
292
|
+
/** Stitch the live generation onto a row read back from disk. */
|
|
293
|
+
withGeneration(agent) {
|
|
294
|
+
const generation = this.agentGenerations.get(agent.sourceId);
|
|
295
|
+
return generation === undefined ? agent : { ...agent, generation };
|
|
228
296
|
}
|
|
229
297
|
setAgentPinned(sourceId, pinned) {
|
|
230
298
|
if (this.getAgent(sourceId) === undefined) {
|
|
@@ -960,6 +1028,115 @@ export class WebStore {
|
|
|
960
1028
|
WHERE source_id = ? AND job_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
961
1029
|
`).run(input.sourceId, input.jobId, input.deliveryKey);
|
|
962
1030
|
}
|
|
1031
|
+
/** Durably claim one Monitor wake before touching the operator. */
|
|
1032
|
+
reserveMonitorWake(input) {
|
|
1033
|
+
const monitor = parseMonitorProjection(input.monitor);
|
|
1034
|
+
if (monitor.monitorId !== input.monitorId) {
|
|
1035
|
+
throw new WebConsoleError("invalid_notification", "The Monitor projection does not match its delivery identity.", 409);
|
|
1036
|
+
}
|
|
1037
|
+
const thread = this.database.prepare("SELECT source_id, archived_at, trigger_kind FROM threads WHERE id = ?")
|
|
1038
|
+
.get(input.threadId);
|
|
1039
|
+
if (thread === undefined || thread.source_id !== input.sourceId) {
|
|
1040
|
+
throw new WebConsoleError("invalid_notification", "The Monitor wake does not match its web thread.", 409);
|
|
1041
|
+
}
|
|
1042
|
+
if (thread.archived_at !== null || thread.trigger_kind !== null) {
|
|
1043
|
+
throw new WebConsoleError("thread_archived", "The Monitor wake destination is not an active web conversation.", 409);
|
|
1044
|
+
}
|
|
1045
|
+
const existing = this.database.prepare(`
|
|
1046
|
+
SELECT monitor_id, thread_id, payload_sha256, state, disposition
|
|
1047
|
+
FROM monitor_wake_deliveries WHERE source_id = ? AND delivery_key = ?
|
|
1048
|
+
`).get(input.sourceId, input.deliveryKey);
|
|
1049
|
+
if (existing !== undefined) {
|
|
1050
|
+
if (existing.monitor_id !== input.monitorId
|
|
1051
|
+
|| existing.thread_id !== input.threadId
|
|
1052
|
+
|| existing.payload_sha256 !== input.payloadSha256) {
|
|
1053
|
+
throw new WebConsoleError("notification_idempotency_conflict", "The Monitor delivery key was already used for a different wake.", 409);
|
|
1054
|
+
}
|
|
1055
|
+
if (existing.state === "completed"
|
|
1056
|
+
&& (existing.disposition === "steered" || existing.disposition === "follow_up")) {
|
|
1057
|
+
return { kind: "completed", disposition: existing.disposition };
|
|
1058
|
+
}
|
|
1059
|
+
return { kind: "uncertain" };
|
|
1060
|
+
}
|
|
1061
|
+
this.database.prepare(`
|
|
1062
|
+
INSERT INTO monitor_wake_deliveries (
|
|
1063
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
|
|
1064
|
+
state, disposition, turn_id, created_at, completed_at
|
|
1065
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'accepted', NULL, NULL, ?, NULL)
|
|
1066
|
+
`).run(input.sourceId, input.monitorId, input.deliveryKey, input.threadId, input.payloadSha256, JSON.stringify(monitor), this.now());
|
|
1067
|
+
return { kind: "new" };
|
|
1068
|
+
}
|
|
1069
|
+
completeMonitorWake(input) {
|
|
1070
|
+
let messageId;
|
|
1071
|
+
this.transaction(() => {
|
|
1072
|
+
const result = this.database.prepare(`
|
|
1073
|
+
UPDATE monitor_wake_deliveries
|
|
1074
|
+
SET state = 'completed', disposition = ?, turn_id = ?, completed_at = ?
|
|
1075
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
1076
|
+
`).run(input.disposition, input.turnId ?? null, this.now(), input.sourceId, input.monitorId, input.deliveryKey);
|
|
1077
|
+
if (result.changes !== 1) {
|
|
1078
|
+
throw new WebConsoleError("notification_reservation_lost", "The Monitor wake reservation was lost.", 409);
|
|
1079
|
+
}
|
|
1080
|
+
if (input.turnId === undefined)
|
|
1081
|
+
return;
|
|
1082
|
+
const turn = this.requireTurn(input.turnId);
|
|
1083
|
+
const reservation = this.database.prepare(`
|
|
1084
|
+
SELECT thread_id, projection_json FROM monitor_wake_deliveries
|
|
1085
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ?
|
|
1086
|
+
`).get(input.sourceId, input.monitorId, input.deliveryKey);
|
|
1087
|
+
if (reservation?.thread_id !== turn.thread_id) {
|
|
1088
|
+
throw new WebConsoleError("monitor_origin_mismatch", "The Monitor activity does not belong to this turn.", 409);
|
|
1089
|
+
}
|
|
1090
|
+
if (reservation.projection_json === null)
|
|
1091
|
+
return;
|
|
1092
|
+
let projection;
|
|
1093
|
+
try {
|
|
1094
|
+
projection = parseMonitorProjection(JSON.parse(reservation.projection_json));
|
|
1095
|
+
if (projection.monitorId !== input.monitorId)
|
|
1096
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
1100
|
+
}
|
|
1101
|
+
const message = this.requireMessage(turn.assistant_message_id);
|
|
1102
|
+
const parts = [...message.parts];
|
|
1103
|
+
if (!upsertMonitorActivity(parts, projection, input.deliveryKey))
|
|
1104
|
+
return;
|
|
1105
|
+
const now = this.now();
|
|
1106
|
+
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
1107
|
+
.run(serializeParts(parts), now, message.id);
|
|
1108
|
+
messageId = message.id;
|
|
1109
|
+
});
|
|
1110
|
+
return messageId === undefined ? undefined : this.requireMessage(messageId);
|
|
1111
|
+
}
|
|
1112
|
+
/** Resolve only an exact Monitor live-input receipt belonging to this turn. */
|
|
1113
|
+
monitorWakeProjection(turnId, deliveryKey) {
|
|
1114
|
+
const row = this.database.prepare(`
|
|
1115
|
+
SELECT deliveries.monitor_id, deliveries.projection_json
|
|
1116
|
+
FROM monitor_wake_deliveries AS deliveries
|
|
1117
|
+
JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
|
|
1118
|
+
JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
|
|
1119
|
+
WHERE deliveries.delivery_key = ?
|
|
1120
|
+
`).get(turnId, deliveryKey);
|
|
1121
|
+
if (row === undefined || row.projection_json === null)
|
|
1122
|
+
return undefined;
|
|
1123
|
+
try {
|
|
1124
|
+
const projection = parseMonitorProjection(JSON.parse(row.projection_json));
|
|
1125
|
+
if (projection.monitorId !== row.monitor_id)
|
|
1126
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
1127
|
+
return projection;
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** Release a Monitor reservation only before any operator delivery begins. */
|
|
1134
|
+
abandonMonitorWake(input) {
|
|
1135
|
+
this.database.prepare(`
|
|
1136
|
+
DELETE FROM monitor_wake_deliveries
|
|
1137
|
+
WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
|
|
1138
|
+
`).run(input.sourceId, input.monitorId, input.deliveryKey);
|
|
1139
|
+
}
|
|
963
1140
|
/** Exact retained binding used before proxying a single operator job. */
|
|
964
1141
|
processJobCardBelongsToThread(sourceId, threadId, jobId) {
|
|
965
1142
|
return this.database.prepare(`
|
|
@@ -1234,14 +1411,44 @@ export class WebStore {
|
|
|
1234
1411
|
this.setSetting("current_thread_id", resolved);
|
|
1235
1412
|
}
|
|
1236
1413
|
patchThread(id, patch) {
|
|
1414
|
+
return this.transaction(() => this.writeThreadPatch(id, patch));
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Compare-and-set: apply `patch` only while the conversation still carries no
|
|
1418
|
+
* run override, and report which way it went.
|
|
1419
|
+
*
|
|
1420
|
+
* The console's one-time adoption of a browser-local override is the caller.
|
|
1421
|
+
* Read and write are ONE `BEGIN IMMEDIATE` here rather than two statements
|
|
1422
|
+
* either side of a service-level check: the process lease
|
|
1423
|
+
* (`state-paths.ts`) is held on a different database file for the service
|
|
1424
|
+
* lifetime, so it makes this process the only *service* writing, not this
|
|
1425
|
+
* statement the only writer of `web.sqlite`. Any other connection to the
|
|
1426
|
+
* state DB -- a maintenance script, a second console pointed at the same
|
|
1427
|
+
* state dir -- could land between a bare read and a bare write, and did in a
|
|
1428
|
+
* probe.
|
|
1429
|
+
*/
|
|
1430
|
+
patchThreadIfRunConfigUnset(id, patch) {
|
|
1431
|
+
return this.transaction(() => {
|
|
1432
|
+
const resolved = this.resolveThreadId(id);
|
|
1433
|
+
const current = this.requireThread(resolved);
|
|
1434
|
+
if (current.runModel !== null || current.runEffort !== null) {
|
|
1435
|
+
return { applied: false, thread: { ...current, sourceId: current.sourceId } };
|
|
1436
|
+
}
|
|
1437
|
+
return { applied: true, thread: this.writeThreadPatch(resolved, patch) };
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
/** The body of {@link patchThread}. Assumes an open transaction. */
|
|
1441
|
+
writeThreadPatch(id, patch) {
|
|
1237
1442
|
id = this.resolveThreadId(id);
|
|
1238
1443
|
const current = this.requireThread(id);
|
|
1239
1444
|
const now = this.now();
|
|
1240
1445
|
const title = patch.title === undefined ? undefined : normalizeTitle(patch.title);
|
|
1241
1446
|
const archivedAt = patch.archived === undefined ? undefined : patch.archived ? now : null;
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1447
|
+
const runModel = patch.model === undefined ? undefined : patch.model;
|
|
1448
|
+
const runEffort = patch.effort === undefined ? undefined : patch.effort;
|
|
1449
|
+
{
|
|
1450
|
+
const sets = [];
|
|
1451
|
+
const values = [];
|
|
1245
1452
|
if (title !== undefined) {
|
|
1246
1453
|
sets.push("title = ?", "title_manual = 1");
|
|
1247
1454
|
values.push(title);
|
|
@@ -1250,13 +1457,34 @@ export class WebStore {
|
|
|
1250
1457
|
sets.push("archived_at = ?");
|
|
1251
1458
|
values.push(archivedAt);
|
|
1252
1459
|
}
|
|
1460
|
+
if (runModel !== undefined) {
|
|
1461
|
+
sets.push("run_model = ?");
|
|
1462
|
+
values.push(runModel);
|
|
1463
|
+
}
|
|
1464
|
+
if (runEffort !== undefined) {
|
|
1465
|
+
sets.push("run_effort = ?");
|
|
1466
|
+
values.push(runEffort);
|
|
1467
|
+
}
|
|
1468
|
+
// A model/effort-only patch must not reorder the sidebar, so `updated_at`
|
|
1469
|
+
// only advances when title or archived state actually changes.
|
|
1470
|
+
if (title !== undefined || archivedAt !== undefined) {
|
|
1471
|
+
sets.push("updated_at = ?");
|
|
1472
|
+
values.push(now);
|
|
1473
|
+
}
|
|
1474
|
+
sets.push("revision = revision + 1");
|
|
1253
1475
|
values.push(id);
|
|
1254
1476
|
this.database.prepare(`UPDATE threads SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
1255
|
-
this.recordThreadRevision(id, title !== undefined
|
|
1477
|
+
this.recordThreadRevision(id, title !== undefined
|
|
1478
|
+
? "title_changed"
|
|
1479
|
+
: archivedAt !== undefined
|
|
1480
|
+
? patch.archived
|
|
1481
|
+
? "archived"
|
|
1482
|
+
: "unarchived"
|
|
1483
|
+
: "run_config_changed", now);
|
|
1256
1484
|
if (patch.archived === true && this.currentThreadId() === id) {
|
|
1257
1485
|
this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id'").run();
|
|
1258
1486
|
}
|
|
1259
|
-
}
|
|
1487
|
+
}
|
|
1260
1488
|
return { ...this.requireThread(id), sourceId: current.sourceId };
|
|
1261
1489
|
}
|
|
1262
1490
|
/** Whether the current interactive thread still accepts agent-proposed titles. */
|
|
@@ -1590,7 +1818,7 @@ export class WebStore {
|
|
|
1590
1818
|
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
1591
1819
|
started_at, finished_at, error_code, error_message
|
|
1592
1820
|
) VALUES (?, ?, 'running', ?, NULL, NULL, ?, ?, NULL, NULL, NULL)
|
|
1593
|
-
`).run(turnId, threadId, input.prompt, assistantMessageId, now);
|
|
1821
|
+
`).run(turnId, threadId, input.storedPrompt ?? input.prompt, assistantMessageId, now);
|
|
1594
1822
|
this.database.prepare(`
|
|
1595
1823
|
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
1596
1824
|
VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
|
|
@@ -1810,7 +2038,7 @@ export class WebStore {
|
|
|
1810
2038
|
replaceWholeText(parts, frame.text);
|
|
1811
2039
|
}
|
|
1812
2040
|
else if (frame.kind === "event") {
|
|
1813
|
-
applyEvent(parts, frame.event);
|
|
2041
|
+
applyEvent(parts, frame.event, (deliveryKey) => this.monitorWakeProjection(turnId, deliveryKey));
|
|
1814
2042
|
if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
|
|
1815
2043
|
if (typeof frame.event.data?.model === "string")
|
|
1816
2044
|
actualModel = frame.event.data.model;
|
|
@@ -1834,9 +2062,9 @@ export class WebStore {
|
|
|
1834
2062
|
});
|
|
1835
2063
|
return this.requireMessage(message.id);
|
|
1836
2064
|
}
|
|
1837
|
-
completeTurn(turnId, finalText, metadata, replyParts) {
|
|
2065
|
+
completeTurn(turnId, finalText, metadata, replyParts, options = {}) {
|
|
1838
2066
|
const runtime = runtimeMetadata(metadata);
|
|
1839
|
-
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts);
|
|
2067
|
+
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true);
|
|
1840
2068
|
}
|
|
1841
2069
|
failTurn(turnId, error) {
|
|
1842
2070
|
return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
|
|
@@ -2207,6 +2435,7 @@ export class WebStore {
|
|
|
2207
2435
|
default_effort TEXT,
|
|
2208
2436
|
efforts_json TEXT,
|
|
2209
2437
|
model_options_json TEXT,
|
|
2438
|
+
providers_json TEXT,
|
|
2210
2439
|
cron_read INTEGER NOT NULL DEFAULT 0,
|
|
2211
2440
|
cron_actions INTEGER NOT NULL DEFAULT 0,
|
|
2212
2441
|
ask_by_id INTEGER NOT NULL DEFAULT 0,
|
|
@@ -2222,6 +2451,8 @@ export class WebStore {
|
|
|
2222
2451
|
archived_at TEXT,
|
|
2223
2452
|
created_at TEXT NOT NULL,
|
|
2224
2453
|
updated_at TEXT NOT NULL,
|
|
2454
|
+
run_model TEXT,
|
|
2455
|
+
run_effort TEXT,
|
|
2225
2456
|
revision INTEGER NOT NULL DEFAULT 1
|
|
2226
2457
|
);
|
|
2227
2458
|
CREATE TABLE IF NOT EXISTS turns (
|
|
@@ -2324,6 +2555,22 @@ export class WebStore {
|
|
|
2324
2555
|
);
|
|
2325
2556
|
CREATE INDEX IF NOT EXISTS process_job_wake_deliveries_by_thread
|
|
2326
2557
|
ON process_job_wake_deliveries(thread_id, created_at);
|
|
2558
|
+
CREATE TABLE IF NOT EXISTS monitor_wake_deliveries (
|
|
2559
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2560
|
+
monitor_id TEXT NOT NULL,
|
|
2561
|
+
delivery_key TEXT NOT NULL,
|
|
2562
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
|
|
2563
|
+
payload_sha256 TEXT NOT NULL,
|
|
2564
|
+
projection_json TEXT,
|
|
2565
|
+
state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
|
|
2566
|
+
disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
|
|
2567
|
+
turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
|
|
2568
|
+
created_at TEXT NOT NULL,
|
|
2569
|
+
completed_at TEXT,
|
|
2570
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
2571
|
+
);
|
|
2572
|
+
CREATE INDEX IF NOT EXISTS monitor_wake_deliveries_by_thread
|
|
2573
|
+
ON monitor_wake_deliveries(thread_id, created_at);
|
|
2327
2574
|
CREATE TABLE IF NOT EXISTS cron_channels (
|
|
2328
2575
|
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2329
2576
|
job_id TEXT NOT NULL,
|
|
@@ -2477,6 +2724,41 @@ export class WebStore {
|
|
|
2477
2724
|
this.database.exec("ALTER TABLE attachments ADD COLUMN origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply'))");
|
|
2478
2725
|
}
|
|
2479
2726
|
}
|
|
2727
|
+
// Per-conversation model/effort overrides are server-persisted columns.
|
|
2728
|
+
// Guard on PRAGMA table_info so the ALTER is skipped when the columns
|
|
2729
|
+
// already exist, keeping the migration re-runnable.
|
|
2730
|
+
if (versionRow.user_version < 11) {
|
|
2731
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(threads)").all()
|
|
2732
|
+
.map((column) => column.name));
|
|
2733
|
+
if (!columns.has("run_model")) {
|
|
2734
|
+
this.database.exec("ALTER TABLE threads ADD COLUMN run_model TEXT");
|
|
2735
|
+
}
|
|
2736
|
+
if (!columns.has("run_effort")) {
|
|
2737
|
+
this.database.exec("ALTER TABLE threads ADD COLUMN run_effort TEXT");
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
// The provider summary an agent advertises. Guarded on PRAGMA
|
|
2741
|
+
// table_info so the ALTER is skipped when the column already exists,
|
|
2742
|
+
// keeping the migration re-runnable after an interrupted upgrade.
|
|
2743
|
+
if (versionRow.user_version < 12) {
|
|
2744
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(agents)").all()
|
|
2745
|
+
.map((column) => column.name));
|
|
2746
|
+
if (!columns.has("providers_json")) {
|
|
2747
|
+
this.database.exec("ALTER TABLE agents ADD COLUMN providers_json TEXT");
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
// Schema v13 tied the Monitor idempotency ledger to the conversation
|
|
2751
|
+
// with ON DELETE CASCADE. That erased the tombstone and allowed a
|
|
2752
|
+
// delivery key to name different content after thread deletion.
|
|
2753
|
+
if (versionRow.user_version === 13)
|
|
2754
|
+
this.migrateMonitorWakeDeliveries();
|
|
2755
|
+
if (versionRow.user_version < 15) {
|
|
2756
|
+
const columns = new Set(this.database.prepare("PRAGMA table_info(monitor_wake_deliveries)").all()
|
|
2757
|
+
.map((column) => column.name));
|
|
2758
|
+
if (!columns.has("projection_json")) {
|
|
2759
|
+
this.database.exec("ALTER TABLE monitor_wake_deliveries ADD COLUMN projection_json TEXT");
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2480
2762
|
if (migrating)
|
|
2481
2763
|
this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
|
|
2482
2764
|
}
|
|
@@ -2493,6 +2775,36 @@ export class WebStore {
|
|
|
2493
2775
|
throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
|
|
2494
2776
|
}
|
|
2495
2777
|
}
|
|
2778
|
+
/** Preserve Monitor delivery tombstones while making deleted threads threadless. */
|
|
2779
|
+
migrateMonitorWakeDeliveries() {
|
|
2780
|
+
this.database.exec(`
|
|
2781
|
+
CREATE TABLE monitor_wake_deliveries_v14 (
|
|
2782
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
2783
|
+
monitor_id TEXT NOT NULL,
|
|
2784
|
+
delivery_key TEXT NOT NULL,
|
|
2785
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
|
|
2786
|
+
payload_sha256 TEXT NOT NULL,
|
|
2787
|
+
projection_json TEXT,
|
|
2788
|
+
state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
|
|
2789
|
+
disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
|
|
2790
|
+
turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
|
|
2791
|
+
created_at TEXT NOT NULL,
|
|
2792
|
+
completed_at TEXT,
|
|
2793
|
+
PRIMARY KEY (source_id, delivery_key)
|
|
2794
|
+
);
|
|
2795
|
+
INSERT INTO monitor_wake_deliveries_v14 (
|
|
2796
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
|
|
2797
|
+
state, disposition, turn_id, created_at, completed_at
|
|
2798
|
+
) SELECT
|
|
2799
|
+
source_id, monitor_id, delivery_key, thread_id, payload_sha256, NULL,
|
|
2800
|
+
state, disposition, turn_id, created_at, completed_at
|
|
2801
|
+
FROM monitor_wake_deliveries;
|
|
2802
|
+
DROP TABLE monitor_wake_deliveries;
|
|
2803
|
+
ALTER TABLE monitor_wake_deliveries_v14 RENAME TO monitor_wake_deliveries;
|
|
2804
|
+
CREATE INDEX monitor_wake_deliveries_by_thread
|
|
2805
|
+
ON monitor_wake_deliveries(thread_id, created_at);
|
|
2806
|
+
`);
|
|
2807
|
+
}
|
|
2496
2808
|
/**
|
|
2497
2809
|
* Schema-v5 adoption runs after every older fixup inside the same
|
|
2498
2810
|
* BEGIN IMMEDIATE transaction. Each operation is guarded by the resulting
|
|
@@ -2642,6 +2954,8 @@ export class WebStore {
|
|
|
2642
2954
|
"revisions",
|
|
2643
2955
|
"settings",
|
|
2644
2956
|
"notification_deliveries",
|
|
2957
|
+
"process_job_wake_deliveries",
|
|
2958
|
+
"monitor_wake_deliveries",
|
|
2645
2959
|
"cron_channels",
|
|
2646
2960
|
"cron_channel_deletions",
|
|
2647
2961
|
"cron_overviews",
|
|
@@ -2669,6 +2983,21 @@ export class WebStore {
|
|
|
2669
2983
|
throw new WebConsoleError("storage_corrupt", `Message ${message.id} contains invalid persisted parts.`, 500);
|
|
2670
2984
|
}
|
|
2671
2985
|
}
|
|
2986
|
+
const monitorProjections = this.database.prepare(`
|
|
2987
|
+
SELECT monitor_id, projection_json
|
|
2988
|
+
FROM monitor_wake_deliveries
|
|
2989
|
+
WHERE projection_json IS NOT NULL
|
|
2990
|
+
`).all();
|
|
2991
|
+
for (const row of monitorProjections) {
|
|
2992
|
+
try {
|
|
2993
|
+
const projection = parseMonitorProjection(JSON.parse(row.projection_json));
|
|
2994
|
+
if (projection.monitorId !== row.monitor_id)
|
|
2995
|
+
throw new TypeError("Monitor identity mismatch.");
|
|
2996
|
+
}
|
|
2997
|
+
catch {
|
|
2998
|
+
throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
2672
3001
|
}
|
|
2673
3002
|
/**
|
|
2674
3003
|
* Index the messages the streaming gate skipped and nothing settled — the
|
|
@@ -2726,17 +3055,17 @@ export class WebStore {
|
|
|
2726
3055
|
WHERE status = 'sending'
|
|
2727
3056
|
`).run(now, now);
|
|
2728
3057
|
}
|
|
2729
|
-
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
3058
|
+
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
|
|
2730
3059
|
const turn = this.requireTurn(turnId);
|
|
2731
3060
|
if (turn.status !== "running") {
|
|
2732
3061
|
return this.requireThreadDetail(turn.thread_id);
|
|
2733
3062
|
}
|
|
2734
3063
|
this.transaction(() => {
|
|
2735
|
-
this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts);
|
|
3064
|
+
this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush);
|
|
2736
3065
|
});
|
|
2737
3066
|
return this.requireThreadDetail(turn.thread_id);
|
|
2738
3067
|
}
|
|
2739
|
-
finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
|
|
3068
|
+
finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
|
|
2740
3069
|
const turn = this.requireTurn(turnId);
|
|
2741
3070
|
if (turn.status !== "running")
|
|
2742
3071
|
return;
|
|
@@ -2768,7 +3097,9 @@ export class WebStore {
|
|
|
2768
3097
|
// Projected cron turns are agent-owned scheduler state. Restart recovery
|
|
2769
3098
|
// still settles the local projection, but only web-owned turns may emit a
|
|
2770
3099
|
// Web Push terminal notification from this service.
|
|
2771
|
-
if (thread.trigger?.kind !== "cron"
|
|
3100
|
+
if (thread.trigger?.kind !== "cron"
|
|
3101
|
+
&& recentEnoughForRecoveredInterruption
|
|
3102
|
+
&& !(status === "complete" && suppressResponsePush)) {
|
|
2772
3103
|
const kind = status === "complete"
|
|
2773
3104
|
? "response.ready"
|
|
2774
3105
|
: status === "cancelled"
|
|
@@ -2831,6 +3162,8 @@ export class WebStore {
|
|
|
2831
3162
|
runState,
|
|
2832
3163
|
canSend: row.can_send === 1,
|
|
2833
3164
|
canUpload: row.can_upload === 1,
|
|
3165
|
+
runModel: row.run_model,
|
|
3166
|
+
runEffort: row.run_effort,
|
|
2834
3167
|
};
|
|
2835
3168
|
}
|
|
2836
3169
|
mapMessage(row) {
|
|
@@ -3124,6 +3457,7 @@ function isValidVapidKeyPair(publicKey, privateKey) {
|
|
|
3124
3457
|
function threadSelectSql(suffix) {
|
|
3125
3458
|
return `
|
|
3126
3459
|
SELECT t.id, t.source_id, t.title, t.title_manual, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,
|
|
3460
|
+
t.run_model, t.run_effort,
|
|
3127
3461
|
cc.job_id AS cron_job_id, cc.configured AS cron_configured,
|
|
3128
3462
|
CASE WHEN t.trigger_kind = 'cron' THEN 0
|
|
3129
3463
|
WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
|
|
@@ -3460,6 +3794,7 @@ function mapAgent(row) {
|
|
|
3460
3794
|
const models = parseStringArray(row.models_json);
|
|
3461
3795
|
const efforts = parseStringArray(row.efforts_json);
|
|
3462
3796
|
const modelOptions = parseRecord(row.model_options_json);
|
|
3797
|
+
const providers = parseProviderSummary(row.providers_json);
|
|
3463
3798
|
return {
|
|
3464
3799
|
sourceId: row.source_id,
|
|
3465
3800
|
label: row.label,
|
|
@@ -3472,6 +3807,7 @@ function mapAgent(row) {
|
|
|
3472
3807
|
...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
|
|
3473
3808
|
...(efforts === undefined ? {} : { efforts }),
|
|
3474
3809
|
...(modelOptions === undefined ? {} : { modelOptions }),
|
|
3810
|
+
...(providers === undefined ? {} : { providers }),
|
|
3475
3811
|
...(row.cron_read === 1
|
|
3476
3812
|
? { cron: { read: true, actions: row.cron_actions === 1 } }
|
|
3477
3813
|
: {}),
|
|
@@ -3519,12 +3855,26 @@ export function toWebAttachment(attachment) {
|
|
|
3519
3855
|
...(attachment.uploaded ? { contentUrl: `/api/v1/uploads/${encodeURIComponent(attachment.id)}/content` } : {}),
|
|
3520
3856
|
};
|
|
3521
3857
|
}
|
|
3522
|
-
function
|
|
3858
|
+
function appliedMonitorWake(event, resolveMonitorWake) {
|
|
3859
|
+
if (resolveMonitorWake === undefined
|
|
3860
|
+
|| event.metadata?.liveInput !== true
|
|
3861
|
+
|| event.metadata?.synthetic !== true
|
|
3862
|
+
|| typeof event.metadata.inputId !== "string") {
|
|
3863
|
+
return undefined;
|
|
3864
|
+
}
|
|
3865
|
+
const projection = resolveMonitorWake(event.metadata.inputId);
|
|
3866
|
+
return projection === undefined
|
|
3867
|
+
? undefined
|
|
3868
|
+
: { deliveryKey: event.metadata.inputId, projection };
|
|
3869
|
+
}
|
|
3870
|
+
function applyEvent(parts, event, resolveMonitorWake) {
|
|
3523
3871
|
if (event.type === "assistant_thought") {
|
|
3524
3872
|
appendTextPart(parts, "reasoning", event.text);
|
|
3525
3873
|
return;
|
|
3526
3874
|
}
|
|
3527
3875
|
if (event.type === "tool_call_started") {
|
|
3876
|
+
if (appliedMonitorWake(event, resolveMonitorWake) !== undefined)
|
|
3877
|
+
return;
|
|
3528
3878
|
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
3529
3879
|
const subagent = subagentOf(event);
|
|
3530
3880
|
if (subagent !== undefined) {
|
|
@@ -3566,6 +3916,11 @@ function applyEvent(parts, event) {
|
|
|
3566
3916
|
return;
|
|
3567
3917
|
}
|
|
3568
3918
|
if (event.type === "tool_call_completed") {
|
|
3919
|
+
const monitorWake = appliedMonitorWake(event, resolveMonitorWake);
|
|
3920
|
+
if (monitorWake !== undefined) {
|
|
3921
|
+
upsertMonitorActivity(parts, monitorWake.projection, monitorWake.deliveryKey);
|
|
3922
|
+
return;
|
|
3923
|
+
}
|
|
3569
3924
|
const status = event.isError === true ? "failed" : "complete";
|
|
3570
3925
|
const historyUpdate = canonicalEventHistoryUpdate(event.history);
|
|
3571
3926
|
const executionMs = canonicalExecutionMs(event.executionMs);
|
|
@@ -3626,6 +3981,33 @@ function applyEvent(parts, event) {
|
|
|
3626
3981
|
}
|
|
3627
3982
|
parts.push({ type: "telemetry", event: event.type, data: event });
|
|
3628
3983
|
}
|
|
3984
|
+
function upsertMonitorActivity(parts, projection, deliveryKey) {
|
|
3985
|
+
const index = parts.findIndex((part) => part.type === "monitor-activity");
|
|
3986
|
+
const previous = index < 0 ? undefined : parts[index];
|
|
3987
|
+
const monitors = previous?.monitors ?? [];
|
|
3988
|
+
const monitorIndex = monitors.findIndex((entry) => entry.projection.monitorId === projection.monitorId);
|
|
3989
|
+
const prior = monitorIndex < 0 ? undefined : monitors[monitorIndex];
|
|
3990
|
+
// The stream receipt and the durable delivery settlement can race. Once an
|
|
3991
|
+
// exact key is present, settlement is bookkeeping only: replacing its newer
|
|
3992
|
+
// projection with the reservation's older snapshot would make the activity
|
|
3993
|
+
// row move backwards and emit a duplicate invalidation.
|
|
3994
|
+
if (prior?.deliveryKeys.includes(deliveryKey) === true)
|
|
3995
|
+
return false;
|
|
3996
|
+
const deliveryKeys = prior === undefined ? [deliveryKey] : [...prior.deliveryKeys, deliveryKey];
|
|
3997
|
+
const entry = { projection, deliveryKeys };
|
|
3998
|
+
const nextMonitors = monitorIndex < 0
|
|
3999
|
+
? [...monitors, entry]
|
|
4000
|
+
: monitors.map((value, at) => at === monitorIndex ? entry : value);
|
|
4001
|
+
const next = { type: "monitor-activity", monitors: nextMonitors };
|
|
4002
|
+
if (index < 0) {
|
|
4003
|
+
parts.push(next);
|
|
4004
|
+
return true;
|
|
4005
|
+
}
|
|
4006
|
+
if (isDeepStrictEqual(previous, next))
|
|
4007
|
+
return false;
|
|
4008
|
+
parts[index] = next;
|
|
4009
|
+
return true;
|
|
4010
|
+
}
|
|
3629
4011
|
function contextCompactionOperationId(value) {
|
|
3630
4012
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
3631
4013
|
return undefined;
|
|
@@ -3781,19 +4163,26 @@ function upsertToolCall(parts, next, historyUpdate) {
|
|
|
3781
4163
|
parts[index] = updated;
|
|
3782
4164
|
}
|
|
3783
4165
|
/**
|
|
3784
|
-
* Whether a stored part
|
|
3785
|
-
*
|
|
3786
|
-
*
|
|
3787
|
-
* prose.
|
|
4166
|
+
* Whether a stored part marks a semantic boundary between streamed text runs.
|
|
4167
|
+
* Most telemetry remains invisible; compaction and the content-free assistant
|
|
4168
|
+
* message marker intentionally keep adjacent provider responses separate.
|
|
3788
4169
|
*/
|
|
3789
|
-
function
|
|
4170
|
+
function separatesStreamedText(part) {
|
|
4171
|
+
// A Monitor acknowledgement may arrive while the provider is still flushing
|
|
4172
|
+
// the preceding message's final text delta. Its compact activity row must not
|
|
4173
|
+
// split that word; the explicit message boundary below separates responses.
|
|
4174
|
+
if (part.type === "monitor-activity")
|
|
4175
|
+
return false;
|
|
3790
4176
|
if (part.type !== "telemetry")
|
|
3791
4177
|
return true;
|
|
3792
4178
|
const event = part.data;
|
|
3793
4179
|
if (event === null || typeof event !== "object" || Array.isArray(event))
|
|
3794
4180
|
return false;
|
|
3795
4181
|
const record = event;
|
|
3796
|
-
return record.type === "runtime_telemetry"
|
|
4182
|
+
return record.type === "runtime_telemetry"
|
|
4183
|
+
&& (record.kind === "context_compaction"
|
|
4184
|
+
|| record.kind === "context_usage"
|
|
4185
|
+
|| record.kind === "assistant_message_boundary");
|
|
3797
4186
|
}
|
|
3798
4187
|
/**
|
|
3799
4188
|
* Append a streamed delta to the trailing run of `type`.
|
|
@@ -3812,7 +4201,7 @@ function appendTextPart(parts, type, delta) {
|
|
|
3812
4201
|
const part = parts[index];
|
|
3813
4202
|
if (part === undefined)
|
|
3814
4203
|
break;
|
|
3815
|
-
if (!
|
|
4204
|
+
if (!separatesStreamedText(part))
|
|
3816
4205
|
continue;
|
|
3817
4206
|
if (part.type !== type)
|
|
3818
4207
|
break;
|
|
@@ -4230,6 +4619,9 @@ function parseParts(value) {
|
|
|
4230
4619
|
if (!Array.isArray(parts) || !parts.every(isWebMessagePart)) {
|
|
4231
4620
|
throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
|
|
4232
4621
|
}
|
|
4622
|
+
if (parts.filter((part) => part.type === "monitor-activity").length > 1) {
|
|
4623
|
+
throw new WebConsoleError("storage_corrupt", "Persisted Monitor activity is duplicated.", 500);
|
|
4624
|
+
}
|
|
4233
4625
|
quoteFromParts(parts);
|
|
4234
4626
|
return parts;
|
|
4235
4627
|
}
|
|
@@ -4453,6 +4845,50 @@ function isWebMessagePart(value) {
|
|
|
4453
4845
|
return false;
|
|
4454
4846
|
}
|
|
4455
4847
|
}
|
|
4848
|
+
if (part.type === "monitor-activity") {
|
|
4849
|
+
if (!hasOnlyKeys(part, new Set(["type", "monitors"]))
|
|
4850
|
+
|| !Array.isArray(part.monitors)
|
|
4851
|
+
|| part.monitors.length === 0
|
|
4852
|
+
|| part.monitors.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
|
|
4853
|
+
return false;
|
|
4854
|
+
}
|
|
4855
|
+
const monitorIds = new Set();
|
|
4856
|
+
let deliveryCount = 0;
|
|
4857
|
+
for (const raw of part.monitors) {
|
|
4858
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
4859
|
+
return false;
|
|
4860
|
+
const entry = raw;
|
|
4861
|
+
if (!hasOnlyKeys(entry, new Set(["projection", "deliveryKeys"])) || !Array.isArray(entry.deliveryKeys)) {
|
|
4862
|
+
return false;
|
|
4863
|
+
}
|
|
4864
|
+
let projection;
|
|
4865
|
+
try {
|
|
4866
|
+
projection = parseMonitorProjection(entry.projection);
|
|
4867
|
+
}
|
|
4868
|
+
catch {
|
|
4869
|
+
return false;
|
|
4870
|
+
}
|
|
4871
|
+
if (monitorIds.has(projection.monitorId)
|
|
4872
|
+
|| entry.deliveryKeys.length === 0
|
|
4873
|
+
|| entry.deliveryKeys.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
|
|
4874
|
+
return false;
|
|
4875
|
+
}
|
|
4876
|
+
monitorIds.add(projection.monitorId);
|
|
4877
|
+
const keys = new Set();
|
|
4878
|
+
for (const key of entry.deliveryKeys) {
|
|
4879
|
+
if (typeof key !== "string"
|
|
4880
|
+
|| key.length === 0
|
|
4881
|
+
|| key.length > 1_024
|
|
4882
|
+
|| /[\u0000-\u001f\u007f]/u.test(key)
|
|
4883
|
+
|| keys.has(key)) {
|
|
4884
|
+
return false;
|
|
4885
|
+
}
|
|
4886
|
+
keys.add(key);
|
|
4887
|
+
}
|
|
4888
|
+
deliveryCount += entry.deliveryKeys.length;
|
|
4889
|
+
}
|
|
4890
|
+
return deliveryCount <= AGENT_LIVE_INPUT_MAX_MESSAGES;
|
|
4891
|
+
}
|
|
4456
4892
|
if (part.type === "telemetry")
|
|
4457
4893
|
return typeof part.event === "string";
|
|
4458
4894
|
if (part.type === "error")
|
|
@@ -4575,6 +5011,38 @@ function parseStringArray(value) {
|
|
|
4575
5011
|
return undefined;
|
|
4576
5012
|
}
|
|
4577
5013
|
}
|
|
5014
|
+
/**
|
|
5015
|
+
* Malformed stored JSON must degrade to "this agent advertises no providers",
|
|
5016
|
+
* never throw: `mapAgent` runs on every bootstrap and discovery read, so a
|
|
5017
|
+
* throw here would take the whole console down over one bad row.
|
|
5018
|
+
*/
|
|
5019
|
+
function parseProviderSummary(value) {
|
|
5020
|
+
if (value === null)
|
|
5021
|
+
return undefined;
|
|
5022
|
+
let parsed;
|
|
5023
|
+
try {
|
|
5024
|
+
parsed = JSON.parse(value);
|
|
5025
|
+
}
|
|
5026
|
+
catch {
|
|
5027
|
+
return undefined;
|
|
5028
|
+
}
|
|
5029
|
+
if (!Array.isArray(parsed))
|
|
5030
|
+
return undefined;
|
|
5031
|
+
const result = [];
|
|
5032
|
+
for (const raw of parsed) {
|
|
5033
|
+
if (typeof raw !== "object" || raw === null)
|
|
5034
|
+
continue;
|
|
5035
|
+
const entry = raw;
|
|
5036
|
+
if (typeof entry.id !== "string" || entry.id.length === 0)
|
|
5037
|
+
continue;
|
|
5038
|
+
result.push({
|
|
5039
|
+
id: entry.id,
|
|
5040
|
+
label: typeof entry.label === "string" && entry.label.length > 0 ? entry.label : entry.id,
|
|
5041
|
+
...(entry.configured === true ? { configured: true } : {}),
|
|
5042
|
+
});
|
|
5043
|
+
}
|
|
5044
|
+
return result.length === 0 ? undefined : result;
|
|
5045
|
+
}
|
|
4578
5046
|
function parseRecord(value) {
|
|
4579
5047
|
if (value === null)
|
|
4580
5048
|
return undefined;
|