@mono-agent/web 0.20.11 → 0.21.0
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 +397 -68
- package/dist/contracts.d.ts +437 -8
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +2 -0
- package/dist/contracts.js.map +1 -1
- package/dist/cron-reply-context.d.ts +27 -0
- package/dist/cron-reply-context.d.ts.map +1 -0
- package/dist/cron-reply-context.js +241 -0
- package/dist/cron-reply-context.js.map +1 -0
- package/dist/discovery.d.ts +1 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +8 -6
- package/dist/discovery.js.map +1 -1
- package/dist/effort-ladder.d.ts +111 -0
- package/dist/effort-ladder.d.ts.map +1 -0
- package/dist/effort-ladder.js +155 -0
- package/dist/effort-ladder.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/long-lived-fetch.d.ts +5 -0
- package/dist/long-lived-fetch.d.ts.map +1 -0
- package/dist/long-lived-fetch.js +22 -0
- package/dist/long-lived-fetch.js.map +1 -0
- package/dist/monitor-reply.d.ts +10 -0
- package/dist/monitor-reply.d.ts.map +1 -0
- package/dist/monitor-reply.js +67 -0
- package/dist/monitor-reply.js.map +1 -0
- package/dist/notification-client.d.ts +11 -2
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +5 -4
- 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 +35 -2
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +276 -9
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts +42 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +679 -52
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +299 -18
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1915 -160
- package/dist/service.js.map +1 -1
- package/dist/store-migrations.d.ts +23 -0
- package/dist/store-migrations.d.ts.map +1 -0
- package/dist/store-migrations.js +212 -0
- package/dist/store-migrations.js.map +1 -0
- package/dist/store.d.ts +389 -21
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2087 -245
- package/dist/store.js.map +1 -1
- package/package.json +9 -8
- package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
- package/webapp/dist/assets/index-6uQ7TVQe.css +1 -0
- package/webapp/dist/assets/index-CsSMjSgW.js +156 -0
- package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
- package/webapp/dist/badge-96.png +0 -0
- package/webapp/dist/index.html +26 -6
- package/webapp/dist/manifest.webmanifest +1 -1
- package/webapp/dist/notification-sw.js +3 -1
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
- package/webapp/dist/assets/index-BT463dRM.css +0 -1
- package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
package/dist/service.js
CHANGED
|
@@ -1,26 +1,298 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
3
|
+
import { AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES, AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE, AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
4
4
|
import { EFFORT_LEVELS } from "@mono-agent/config";
|
|
5
5
|
import { WEB_API_VERSION, WEB_MAX_CONCURRENT_UPLOADS, WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, WEB_MAX_FILES_PER_TURN, WEB_MAX_STAGED_UPLOAD_BYTES, WEB_MAX_STAGED_UPLOADS, WEB_MAX_QUEUED_ATTACHMENT_TURNS, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_STAGED_UPLOAD_TTL_MS, } from "./contracts.js";
|
|
6
6
|
import { discoverOperatorAgents, } from "./discovery.js";
|
|
7
7
|
import { conversationTitleFromFrame } from "./conversation-title.js";
|
|
8
|
+
import { parseCronReplyContext } from "./cron-reply-context.js";
|
|
9
|
+
import { advertisedEffortLevels, effectiveModelForAgent, effortLevelsForModel, inheritedEffortForModel, } from "./effort-ladder.js";
|
|
8
10
|
import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
|
|
9
11
|
import { OperatorClient } from "./operator-client.js";
|
|
10
12
|
import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
|
|
11
13
|
import { acquireWebStateLease, prepareWebStatePaths } from "./state-paths.js";
|
|
12
|
-
import { cronChannelReadOnlyError, toWebAttachment, WebStore, notificationPushLogicalKey, } from "./store.js";
|
|
14
|
+
import { cronChannelReadOnlyError, toWebAttachment, WebStore, WEB_THREAD_PAGE_DEFAULT, notificationPushLogicalKey, } from "./store.js";
|
|
13
15
|
const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
|
|
14
16
|
const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
15
17
|
const INFO_TIMEOUT_MS = 2_500;
|
|
16
18
|
const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
|
|
19
|
+
/** Bounded per-agent catalog-admitted model refs; beyond it, oldest go first. */
|
|
20
|
+
const MODEL_CATALOG_CACHE_CAP = 2_048;
|
|
21
|
+
/** Matches the browser picker: enough for today's 200-row provider ceiling,
|
|
22
|
+
* with slack for an older or independently implemented operator. */
|
|
23
|
+
const MODEL_CATALOG_RESTORE_PAGE_SIZE = 100;
|
|
24
|
+
const MODEL_CATALOG_RESTORE_PAGE_LIMIT = 5;
|
|
17
25
|
const REPLY_ACCESS_TTL_MS = 10 * 60 * 1_000;
|
|
26
|
+
/**
|
|
27
|
+
* How coarsely a reply capability's expiry is quantised.
|
|
28
|
+
*
|
|
29
|
+
* The expiry is part of the signed URL, so an expiry read off the wall clock
|
|
30
|
+
* made every projection of the same message a DIFFERENT transcript: the
|
|
31
|
+
* conversation's ETag moved once a second for the life of a running turn and no
|
|
32
|
+
* console could ever be answered with a 304, however little had changed. Rounded
|
|
33
|
+
* DOWN to the bucket the mint falls in, the URL is stable for the bucket and a
|
|
34
|
+
* key's life is between five and ten minutes -- shortened, never extended, so
|
|
35
|
+
* the validator's ceiling is untouched.
|
|
36
|
+
*/
|
|
37
|
+
const REPLY_ACCESS_BUCKET_MS = 5 * 60 * 1_000;
|
|
18
38
|
/**
|
|
19
39
|
* Raster types the console keeps its own copy of. `image/svg+xml` is absent on
|
|
20
40
|
* purpose: it is active content, and both the inline gate in the browser and
|
|
21
41
|
* `setReplyDownloadHeaders` already refuse to treat it as an image.
|
|
22
42
|
*/
|
|
23
43
|
const REPLY_IMAGE_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
44
|
+
/**
|
|
45
|
+
* How much of a tool call's `result`/`args` a browser read carries.
|
|
46
|
+
*
|
|
47
|
+
* Measured on a real fleet transcript: a 4-message tool-heavy conversation is
|
|
48
|
+
* 231 KB, of which 211 KB is `tool-call` result bodies (single `Exec`/`ReadSkill`
|
|
49
|
+
* results run 14-21 KB). The rows are collapsed, so what the operator sees of
|
|
50
|
+
* one is its first screenful; the rest is a request away.
|
|
51
|
+
*/
|
|
52
|
+
const TOOL_PAYLOAD_PREVIEW_CHARS = 4_096;
|
|
53
|
+
/**
|
|
54
|
+
* Telemetry whose `data` the console actually reads.
|
|
55
|
+
*
|
|
56
|
+
* `webapp/src/runtime.tsx` renders only `runtime_telemetry` of kind
|
|
57
|
+
* `context_compaction`/`assistant_message_boundary`/`context_usage` plus
|
|
58
|
+
* `cron_run`; `webapp/src/usage.ts` sums any part labelled `context_usage`,
|
|
59
|
+
* `context_compaction`, or containing `usage`/`cost`. Everything else --
|
|
60
|
+
* `provider_status`, `memory_recalled`, `status`, `run_config`, `cache_hit`,
|
|
61
|
+
* `capabilities_resolved`, ... -- is carried across the wire and dropped, which
|
|
62
|
+
* is ~10 KB a conversation.
|
|
63
|
+
*/
|
|
64
|
+
const TELEMETRY_DATA_ALLOWLIST = new Set([
|
|
65
|
+
"usage_update",
|
|
66
|
+
"cron_run",
|
|
67
|
+
"context_usage",
|
|
68
|
+
"context_compaction",
|
|
69
|
+
"assistant_message_boundary",
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Whether a tool name IS AskUser, however the agent qualified it.
|
|
73
|
+
*
|
|
74
|
+
* An MCP server serves it as `mcp__<server>__ask_user`, a forwarding runtime as
|
|
75
|
+
* `some.namespace:AskUser`, and separators vary. Two places have to agree on
|
|
76
|
+
* this -- the frame observer that arms the interaction poller, and the shaper
|
|
77
|
+
* that must leave the card's question and answer alone -- so they read the same
|
|
78
|
+
* rule rather than two spellings of it. An exact `=== "AskUser"` here silently
|
|
79
|
+
* shaped the card's payload for every run that routes the tool through a server.
|
|
80
|
+
*/
|
|
81
|
+
function isAskUserToolName(toolName) {
|
|
82
|
+
return toolNameLeaf(toolName).toLowerCase().replace(/[^a-z0-9]+/gu, "") === "askuser";
|
|
83
|
+
}
|
|
84
|
+
/** A `runtime_telemetry` event's variant, which the store stores inside `data`. */
|
|
85
|
+
function telemetryKind(data) {
|
|
86
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
87
|
+
return undefined;
|
|
88
|
+
const kind = data.kind;
|
|
89
|
+
return typeof kind === "string" && kind.length > 0 ? kind : undefined;
|
|
90
|
+
}
|
|
91
|
+
/** The allowlist, or the `usage`/`cost` substring rule `usage.ts` applies. */
|
|
92
|
+
function readsTelemetryLabel(label) {
|
|
93
|
+
if (label === undefined)
|
|
94
|
+
return false;
|
|
95
|
+
const normalized = label.toLowerCase();
|
|
96
|
+
return TELEMETRY_DATA_ALLOWLIST.has(normalized)
|
|
97
|
+
|| normalized.includes("usage")
|
|
98
|
+
|| normalized.includes("cost");
|
|
99
|
+
}
|
|
100
|
+
function keepsTelemetryData(part) {
|
|
101
|
+
// `usage.ts` matches on the event name AND on a nested `kind`, so both are
|
|
102
|
+
// measured against the same rule -- a `runtime_telemetry{kind:"token_usage"}`
|
|
103
|
+
// counts towards the run's tokens exactly like a bare `usage_update`.
|
|
104
|
+
return readsTelemetryLabel(part.event) || readsTelemetryLabel(telemetryKind(part.data));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Keep the part, drop the payload the console never reads.
|
|
108
|
+
*
|
|
109
|
+
* Removing it outright would renumber every later part, and both the client's
|
|
110
|
+
* part conversion and the index-based transcript reads that follow this change
|
|
111
|
+
* depend on positions being stable. `kind` is surfaced whenever the event has
|
|
112
|
+
* one -- kept or stripped -- so its presence is never a back-channel for "the
|
|
113
|
+
* payload was dropped".
|
|
114
|
+
*/
|
|
115
|
+
function shapeTelemetryPart(part) {
|
|
116
|
+
const kind = telemetryKind(part.data);
|
|
117
|
+
const keepsData = part.data === undefined || keepsTelemetryData(part);
|
|
118
|
+
if (keepsData && (kind === undefined || part.kind === kind))
|
|
119
|
+
return part;
|
|
120
|
+
return {
|
|
121
|
+
type: "telemetry",
|
|
122
|
+
event: part.event,
|
|
123
|
+
...(kind === undefined ? {} : { kind }),
|
|
124
|
+
...(keepsData ? { data: part.data } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What a truncated payload was cut from, so the console can prove a body it is
|
|
129
|
+
* holding is still that payload.
|
|
130
|
+
*
|
|
131
|
+
* The preview says how long the whole body is and what its first characters
|
|
132
|
+
* are, and a repaired body used to be restored on those two facts alone -- so a
|
|
133
|
+
* rewritten result of the same length with the same head put the OLD body back
|
|
134
|
+
* under the NEW preview. The digest closes that: a console restores only what
|
|
135
|
+
* the server names as the same content, and drops the repair otherwise.
|
|
136
|
+
*
|
|
137
|
+
* Over the SERIALIZED text, which is what both sides already have: a string
|
|
138
|
+
* payload as itself, anything else as its JSON. Nothing here is a secret and
|
|
139
|
+
* nothing is keyed -- it is an identity, not a signature.
|
|
140
|
+
*/
|
|
141
|
+
function payloadDigest(text) {
|
|
142
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The head of an oversized payload, or `undefined` when it is small enough (or
|
|
146
|
+
* cannot be serialized, in which case it is left exactly as stored).
|
|
147
|
+
*/
|
|
148
|
+
function payloadPreview(value) {
|
|
149
|
+
if (value === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
const text = typeof value === "string" ? value : jsonTextOf(value);
|
|
152
|
+
if (text === undefined || text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
|
|
153
|
+
return undefined;
|
|
154
|
+
return {
|
|
155
|
+
preview: text.slice(0, TOOL_PAYLOAD_PREVIEW_CHARS),
|
|
156
|
+
length: text.length,
|
|
157
|
+
digest: payloadDigest(text),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** The same name the shaper gives a payload, for a whole one. See {@link payloadDigest}. */
|
|
161
|
+
function wholePayloadDigest(value) {
|
|
162
|
+
if (value === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
const text = typeof value === "string" ? value : jsonTextOf(value);
|
|
165
|
+
return text === undefined ? undefined : payloadDigest(text);
|
|
166
|
+
}
|
|
167
|
+
/** `undefined` for anything JSON cannot express, which is then left as stored. */
|
|
168
|
+
function jsonTextOf(value) {
|
|
169
|
+
try {
|
|
170
|
+
return JSON.stringify(value);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function shapeToolCall(call) {
|
|
177
|
+
// AskUser's arguments and answer ARE the card the console renders, and they
|
|
178
|
+
// are bounded at the emitter. `structuredResult` is never shaped here: MCP
|
|
179
|
+
// and canonical host outcomes are already bounded and remain opaque.
|
|
180
|
+
if (isAskUserToolName(call.toolName))
|
|
181
|
+
return call;
|
|
182
|
+
const args = shapedArgs(call.args);
|
|
183
|
+
const result = payloadPreview(call.result);
|
|
184
|
+
if (args === undefined && result === undefined)
|
|
185
|
+
return call;
|
|
186
|
+
return {
|
|
187
|
+
...call,
|
|
188
|
+
...(args === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: { args: args.preview, argsTruncated: true, argsBytes: args.length, argsDigest: args.digest }),
|
|
191
|
+
...(result === undefined
|
|
192
|
+
? {}
|
|
193
|
+
: { result: result.preview, resultTruncated: true, resultBytes: result.length, resultDigest: result.digest }),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function shapeToolCallPart(part) {
|
|
197
|
+
return { ...shapeToolCall(part), type: "tool-call" };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A tool call's arguments, cut to fit -- serialized ONCE.
|
|
201
|
+
*
|
|
202
|
+
* The size question and the two answers to it all read the same JSON text: the
|
|
203
|
+
* object shaper used to serialize to decide whether it had work, and the
|
|
204
|
+
* whole-value fallback then serialized the same value again to answer the same
|
|
205
|
+
* question. Every under-budget object args paid for both, on every transcript
|
|
206
|
+
* read and now on every streamed delta.
|
|
207
|
+
*/
|
|
208
|
+
function shapedArgs(args) {
|
|
209
|
+
if (args === undefined)
|
|
210
|
+
return undefined;
|
|
211
|
+
const text = typeof args === "string" ? args : jsonTextOf(args);
|
|
212
|
+
// Under budget, or nothing JSON can express: left exactly as stored.
|
|
213
|
+
if (text === undefined || text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
|
|
214
|
+
return undefined;
|
|
215
|
+
const shaped = shapedArgsObject(args, text)
|
|
216
|
+
?? { preview: text.slice(0, TOOL_PAYLOAD_PREVIEW_CHARS), length: text.length };
|
|
217
|
+
return { ...shaped, digest: payloadDigest(text) };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* An oversized ARGUMENTS OBJECT with its string leaves cut back to fit, rather
|
|
221
|
+
* than replaced by the head of its JSON text.
|
|
222
|
+
*
|
|
223
|
+
* Arguments are not opaque the way a result is: the console reads named keys out
|
|
224
|
+
* of them -- `command`, `file_path`, a delegation's `prompt` -- for the row
|
|
225
|
+
* summary and the Task note, and the head of a JSON text is none of those. It is
|
|
226
|
+
* also what an operator scans, and a mid-object slice reads as garbage. Nothing
|
|
227
|
+
* is added or removed, so the object keeps its shape and every key keeps its
|
|
228
|
+
* place, and the longest string pays first.
|
|
229
|
+
*
|
|
230
|
+
* Termination is the bounded pass count, NOT an assumption that every slice
|
|
231
|
+
* shrinks the serialization: cutting between a surrogate pair leaves a lone
|
|
232
|
+
* surrogate that `JSON.stringify` writes as `\udXXX`, which can make one pass
|
|
233
|
+
* longer than the last. Anything still over budget after eight passes falls
|
|
234
|
+
* back to the whole-value head.
|
|
235
|
+
*/
|
|
236
|
+
function shapedArgsObject(args, original) {
|
|
237
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
238
|
+
return undefined;
|
|
239
|
+
let shaped = { ...args };
|
|
240
|
+
for (let pass = 0; pass < 8; pass += 1) {
|
|
241
|
+
const text = jsonTextOf(shaped);
|
|
242
|
+
if (text === undefined)
|
|
243
|
+
return undefined;
|
|
244
|
+
if (text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
|
|
245
|
+
return { preview: shaped, length: original.length };
|
|
246
|
+
const longest = Object.entries(shaped)
|
|
247
|
+
.filter((entry) => typeof entry[1] === "string" && entry[1].length > 1)
|
|
248
|
+
.sort(([, left], [, right]) => right.length - left.length)[0];
|
|
249
|
+
if (longest === undefined)
|
|
250
|
+
return undefined;
|
|
251
|
+
const [key, value] = longest;
|
|
252
|
+
const keep = Math.max(1, value.length - (text.length - TOOL_PAYLOAD_PREVIEW_CHARS));
|
|
253
|
+
shaped = { ...shaped, [key]: value.slice(0, keep) };
|
|
254
|
+
}
|
|
255
|
+
// Too many oversized leaves to fit: fall back to the whole-value head rather
|
|
256
|
+
// than serve an object that is still over budget.
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
function shapeSubagentPart(part) {
|
|
260
|
+
const args = shapedArgs(part.args);
|
|
261
|
+
const result = payloadPreview(part.result);
|
|
262
|
+
return {
|
|
263
|
+
...part,
|
|
264
|
+
...(args === undefined
|
|
265
|
+
? {}
|
|
266
|
+
: { args: args.preview, argsTruncated: true, argsBytes: args.length, argsDigest: args.digest }),
|
|
267
|
+
...(result === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { result: result.preview, resultTruncated: true, resultBytes: result.length, resultDigest: result.digest }),
|
|
270
|
+
calls: part.calls.map(shapeToolCall),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* ONE part's own payloads, named the way a preview of them would be.
|
|
275
|
+
*
|
|
276
|
+
* The repair read serves what is stored; without these the console has nothing
|
|
277
|
+
* to compare a later preview against and, failing closed, would drop every
|
|
278
|
+
* repair it made.
|
|
279
|
+
*
|
|
280
|
+
* Only the part handed to it -- a delegation's `calls` are NOT walked. They do
|
|
281
|
+
* not need to be: a repair is addressed by tool-call id, and a request naming a
|
|
282
|
+
* child is answered by synthesizing that child as the tool-call part it would
|
|
283
|
+
* have been, which comes back through here as a part in its own right.
|
|
284
|
+
*/
|
|
285
|
+
function nameWholePayloads(part) {
|
|
286
|
+
const args = wholePayloadDigest(part.args);
|
|
287
|
+
const result = wholePayloadDigest(part.result);
|
|
288
|
+
if (args === undefined && result === undefined)
|
|
289
|
+
return part;
|
|
290
|
+
return {
|
|
291
|
+
...part,
|
|
292
|
+
...(args === undefined ? {} : { argsDigest: args }),
|
|
293
|
+
...(result === undefined ? {} : { resultDigest: result }),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
24
296
|
function formatQuotedTurn(quote, text) {
|
|
25
297
|
const blockquote = quote
|
|
26
298
|
.trim()
|
|
@@ -29,6 +301,36 @@ function formatQuotedTurn(quote, text) {
|
|
|
29
301
|
.join("\n");
|
|
30
302
|
return `Quoted context:\n${blockquote}\n\n${text}`;
|
|
31
303
|
}
|
|
304
|
+
function assertTurnTextWithinLimit(operatorText) {
|
|
305
|
+
if (operatorText.length <= WEB_MAX_TURN_TEXT_CHARACTERS)
|
|
306
|
+
return;
|
|
307
|
+
throw new WebConsoleError("turn_text_too_large", `The message and quote may contain at most ${WEB_MAX_TURN_TEXT_CHARACTERS} characters after formatting.`, 413);
|
|
308
|
+
}
|
|
309
|
+
function assertMonitorWakeAddress(input) {
|
|
310
|
+
const originConversation = input.monitor.origin.conversationId.split("#", 1)[0];
|
|
311
|
+
const expectedDeliveryKey = `monitor:${input.monitor.monitorId}:${String(input.monitor.counters.seq)}`;
|
|
312
|
+
if (input.monitor.origin.channel !== "web"
|
|
313
|
+
|| originConversation !== `web:${input.threadId}`
|
|
314
|
+
|| input.deliveryKey !== expectedDeliveryKey) {
|
|
315
|
+
throw new WebConsoleError("invalid_notification", "The Monitor wake origin or delivery key does not match its web destination.", 409);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function monitorWakePayloadSha256(monitor, wakePrompt) {
|
|
319
|
+
return createHash("sha256")
|
|
320
|
+
.update(canonicalJson(monitor))
|
|
321
|
+
.update("\0")
|
|
322
|
+
.update(wakePrompt)
|
|
323
|
+
.digest("hex");
|
|
324
|
+
}
|
|
325
|
+
function canonicalJson(value) {
|
|
326
|
+
if (Array.isArray(value))
|
|
327
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
328
|
+
if (typeof value === "object" && value !== null) {
|
|
329
|
+
const record = value;
|
|
330
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
331
|
+
}
|
|
332
|
+
return JSON.stringify(value) ?? "null";
|
|
333
|
+
}
|
|
32
334
|
export class WebService {
|
|
33
335
|
store;
|
|
34
336
|
options;
|
|
@@ -39,9 +341,11 @@ export class WebService {
|
|
|
39
341
|
drainingLiveInputThreads = new Set();
|
|
40
342
|
activeUploads = new Map();
|
|
41
343
|
activeNotifications = new Map();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
344
|
+
activeCronReplies = new Map();
|
|
345
|
+
/** One serialization lane shared by queued user input and every host wake kind. */
|
|
346
|
+
hostWakeTails = new Map();
|
|
347
|
+
hostWakeReservations = new Map();
|
|
348
|
+
activeHostWakes = new Map();
|
|
45
349
|
allowlist = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST);
|
|
46
350
|
attachmentTurnBudget;
|
|
47
351
|
pushIdentity;
|
|
@@ -50,6 +354,20 @@ export class WebService {
|
|
|
50
354
|
replyAccessKey;
|
|
51
355
|
askWatches = new Map();
|
|
52
356
|
connections = new Map();
|
|
357
|
+
/**
|
|
358
|
+
* Source id -> the capability signature projected for it on the last
|
|
359
|
+
* discovery pass. No projected capability is on the summary or in the store,
|
|
360
|
+
* so this is the only record of them that survives a poll -- and the only way
|
|
361
|
+
* `refreshAgentsOnce` can tell that provider authentication itself moved.
|
|
362
|
+
*/
|
|
363
|
+
projectedCapabilities = new Map();
|
|
364
|
+
/** Bounded catalog-admitted model refs per agent, seeded from `modelOptions`
|
|
365
|
+
* and appended to by every proxied `/v1/models` page. Admission is `has`,
|
|
366
|
+
* metadata is `get`. Map preserves insertion order, so evicting the oldest
|
|
367
|
+
* entry is deleting the head. Scoped to the agent GENERATION that filled it
|
|
368
|
+
* (see `reconcileModelCatalogCache`), because a source id outlives the
|
|
369
|
+
* process behind it. */
|
|
370
|
+
modelCatalogCache = new Map();
|
|
53
371
|
/** Parts whose durable copy is being fetched, so concurrent reads fetch once. */
|
|
54
372
|
persistingReplyImages = new Set();
|
|
55
373
|
discoveryTimer;
|
|
@@ -119,8 +437,23 @@ export class WebService {
|
|
|
119
437
|
throw error;
|
|
120
438
|
}
|
|
121
439
|
}
|
|
122
|
-
async bootstrap() {
|
|
440
|
+
async bootstrap(scope = {}) {
|
|
123
441
|
const currentThreadId = this.store.currentThreadId();
|
|
442
|
+
const currentThread = currentThreadId === undefined ? undefined : this.store.getThread(currentThreadId);
|
|
443
|
+
const discoveredCurrentThreadId = currentThread !== undefined
|
|
444
|
+
&& this.store.getAgent(currentThread.sourceId) !== undefined
|
|
445
|
+
? currentThreadId
|
|
446
|
+
: undefined;
|
|
447
|
+
const agents = this.store.listAgents().map((agent) => this.decorateProjectedCapabilities(agent));
|
|
448
|
+
const threadsSourceId = this.bootstrapSourceId(scope.sourceId, currentThread, agents);
|
|
449
|
+
const archived = scope.archived ?? false;
|
|
450
|
+
const page = threadsSourceId === null
|
|
451
|
+
? { threads: [] }
|
|
452
|
+
: this.store.listThreadsPage({
|
|
453
|
+
sourceId: threadsSourceId,
|
|
454
|
+
archived,
|
|
455
|
+
limit: scope.limit ?? WEB_THREAD_PAGE_DEFAULT,
|
|
456
|
+
});
|
|
124
457
|
return {
|
|
125
458
|
version: WEB_API_VERSION,
|
|
126
459
|
push: {
|
|
@@ -128,9 +461,11 @@ export class WebService {
|
|
|
128
461
|
keyFingerprint: this.pushIdentity.fingerprint,
|
|
129
462
|
serviceWorkerVersion: WEB_PUSH_SERVICE_WORKER_VERSION,
|
|
130
463
|
},
|
|
131
|
-
agents
|
|
132
|
-
threads:
|
|
133
|
-
|
|
464
|
+
agents,
|
|
465
|
+
threads: page.threads,
|
|
466
|
+
threadsSourceId,
|
|
467
|
+
threadsNextCursor: page.nextCursor ?? null,
|
|
468
|
+
...(discoveredCurrentThreadId === undefined ? {} : { currentThreadId: discoveredCurrentThreadId }),
|
|
134
469
|
limits: {
|
|
135
470
|
maxFileBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES,
|
|
136
471
|
maxFilesPerTurn: WEB_MAX_FILES_PER_TURN,
|
|
@@ -139,16 +474,83 @@ export class WebService {
|
|
|
139
474
|
},
|
|
140
475
|
};
|
|
141
476
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
477
|
+
/**
|
|
478
|
+
* The one bucket a bootstrap answers with.
|
|
479
|
+
*
|
|
480
|
+
* An absent or unknown `sourceId` is the ordinary case, not an error: the
|
|
481
|
+
* first request a fresh console makes has no selection to name, and a
|
|
482
|
+
* console whose stored agent has since gone away must still get a console.
|
|
483
|
+
* The chain is the one the browser resolves its own selection with -- the
|
|
484
|
+
* agent of the conversation the console was last in, else the first agent
|
|
485
|
+
* that can answer, else the first agent at all.
|
|
486
|
+
*/
|
|
487
|
+
bootstrapSourceId(requested, currentThread, agents) {
|
|
488
|
+
if (requested !== undefined && agents.some((agent) => agent.sourceId === requested))
|
|
489
|
+
return requested;
|
|
490
|
+
const current = currentThread === undefined
|
|
491
|
+
? undefined
|
|
492
|
+
: agents.find((agent) => agent.sourceId === currentThread.sourceId);
|
|
493
|
+
return current?.sourceId
|
|
494
|
+
?? agents.find((agent) => agent.status !== "offline")?.sourceId
|
|
495
|
+
?? agents[0]?.sourceId
|
|
496
|
+
?? null;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The only place provider authentication is projected onto an agent summary.
|
|
500
|
+
*
|
|
501
|
+
* It is derived from the live operator connection's `/v1/info` response.
|
|
502
|
+
* `supportsProviderAuth` has an `agents` column, but the stored presentation
|
|
503
|
+
* bit is never authorization to call an agent. Keeping the field off discovery
|
|
504
|
+
* summaries prevents every heartbeat from looking like a fleet change.
|
|
505
|
+
*
|
|
506
|
+
* Provider authentication also requires a live connection deliberately:
|
|
507
|
+
* `requireProviderAuthConnection` refuses without one, so advertising it to a
|
|
508
|
+
* browser that cannot use it would expose a button whose route only 409s.
|
|
509
|
+
*
|
|
510
|
+
* `refreshAgentsOnce` tracks this projection explicitly so real transitions
|
|
511
|
+
* still emit `agents.changed` without treating heartbeats as changes.
|
|
512
|
+
*/
|
|
513
|
+
decorateProjectedCapabilities(agent) {
|
|
514
|
+
const connection = this.connections.get(agent.sourceId);
|
|
515
|
+
const providerAuth = connection?.info.supportsProviderAuth === true;
|
|
516
|
+
const providerAuthChecks = connection?.info.supportsProviderAuthChecks === true;
|
|
517
|
+
if (!providerAuth)
|
|
518
|
+
return agent;
|
|
519
|
+
return {
|
|
520
|
+
...agent,
|
|
521
|
+
supportsProviderAuth: true,
|
|
522
|
+
...(providerAuthChecks ? { supportsProviderAuthChecks: true } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Compared pass to pass, this says provider authentication moved when
|
|
527
|
+
* nothing on the stored summary did.
|
|
528
|
+
*/
|
|
529
|
+
projectedCapabilitySignature(agent) {
|
|
530
|
+
const projected = this.decorateProjectedCapabilities(agent);
|
|
531
|
+
return [
|
|
532
|
+
projected.supportsProviderAuth === true ? "providerAuth" : "",
|
|
533
|
+
projected.supportsProviderAuthChecks === true ? "providerAuthChecks" : "",
|
|
534
|
+
].join("|");
|
|
535
|
+
}
|
|
536
|
+
createThread(sourceId, input = {}) {
|
|
537
|
+
const agent = this.store.getAgent(sourceId);
|
|
538
|
+
if (agent === undefined) {
|
|
539
|
+
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
540
|
+
}
|
|
541
|
+
const inherited = agent.runSettings.override;
|
|
542
|
+
const model = input.model === undefined ? inherited?.model : input.model ?? undefined;
|
|
543
|
+
const effort = input.effort === undefined ? inherited?.effort : input.effort ?? undefined;
|
|
544
|
+
this.validateModelAndEffort(sourceId, agent, model, effort, input.model === undefined && inherited?.model !== undefined);
|
|
545
|
+
const thread = this.store.createThread(sourceId, input);
|
|
546
|
+
this.emitThread("threads.changed", { thread });
|
|
145
547
|
return thread;
|
|
146
548
|
}
|
|
147
|
-
thread(id) {
|
|
549
|
+
thread(id, options = {}) {
|
|
148
550
|
const detail = this.store.getThreadDetail(id);
|
|
149
551
|
if (detail === undefined)
|
|
150
552
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
151
|
-
return this.decorateThreadDetail(detail);
|
|
553
|
+
return this.decorateThreadDetail(detail, options);
|
|
152
554
|
}
|
|
153
555
|
threadsPage(input) {
|
|
154
556
|
return this.store.listThreadsPage(input);
|
|
@@ -161,8 +563,56 @@ export class WebService {
|
|
|
161
563
|
return this.store.searchThreads(input);
|
|
162
564
|
}
|
|
163
565
|
messagePage(threadId, input) {
|
|
164
|
-
|
|
165
|
-
|
|
566
|
+
// The store pages; the shape is a view concern and has no business reaching it.
|
|
567
|
+
const { full, ...query } = input;
|
|
568
|
+
const page = this.store.listMessagesPage(threadId, query);
|
|
569
|
+
const shape = full === undefined ? {} : { full };
|
|
570
|
+
return { ...page, messages: this.shapeMessages(page.messages, shape) };
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* ONE message, at the version it currently holds.
|
|
574
|
+
*
|
|
575
|
+
* The recovery a streamed transcript needs: a console whose delta no longer
|
|
576
|
+
* chains onto the `seq` it is holding re-reads the one message rather than
|
|
577
|
+
* the conversation around it. Addressed by (conversation, message) for the
|
|
578
|
+
* same reason the tool-call read is -- a message id is not a capability, and
|
|
579
|
+
* a lookup that took it alone would serve any caller any conversation.
|
|
580
|
+
*/
|
|
581
|
+
message(threadId, messageId, options = {}) {
|
|
582
|
+
const thread = this.store.getThread(threadId);
|
|
583
|
+
const message = this.store.getMessage(messageId);
|
|
584
|
+
if (thread === undefined || message === undefined || message.threadId !== thread.id) {
|
|
585
|
+
throw new WebConsoleError("message_not_found", "The message is unavailable.", 404);
|
|
586
|
+
}
|
|
587
|
+
return this.shapeMessage(message, options);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* The untruncated payloads of ONE tool call, for a transcript that was served
|
|
591
|
+
* a preview of it.
|
|
592
|
+
*
|
|
593
|
+
* Addressed by (conversation, message, tool call) rather than by tool-call id
|
|
594
|
+
* alone: the id is not a capability, and a lookup that took it on its own
|
|
595
|
+
* would hand any caller any conversation's transcript.
|
|
596
|
+
*/
|
|
597
|
+
toolCallPart(threadId, messageId, toolCallId) {
|
|
598
|
+
const thread = this.store.getThread(threadId);
|
|
599
|
+
const message = this.store.getMessage(messageId);
|
|
600
|
+
if (thread === undefined || message === undefined || message.threadId !== thread.id) {
|
|
601
|
+
throw new WebConsoleError("tool_call_not_found", "The tool call is unavailable.", 404);
|
|
602
|
+
}
|
|
603
|
+
const owned = message.parts.find((part) => (part.type === "tool-call" || part.type === "subagent") && part.toolCallId === toolCallId);
|
|
604
|
+
if (owned !== undefined)
|
|
605
|
+
return nameWholePayloads(owned);
|
|
606
|
+
for (const part of message.parts) {
|
|
607
|
+
if (part.type !== "subagent")
|
|
608
|
+
continue;
|
|
609
|
+
const call = part.calls.find((candidate) => candidate.toolCallId === toolCallId);
|
|
610
|
+
// A subagent's child owns no part of its own, so it answers as the
|
|
611
|
+
// tool-call part it would have been outside the delegation.
|
|
612
|
+
if (call !== undefined)
|
|
613
|
+
return nameWholePayloads({ type: "tool-call", ...call });
|
|
614
|
+
}
|
|
615
|
+
throw new WebConsoleError("tool_call_not_found", "The tool call is unavailable.", 404);
|
|
166
616
|
}
|
|
167
617
|
/**
|
|
168
618
|
* Re-mint a browser capability from the authoritative durable message. The
|
|
@@ -175,7 +625,7 @@ export class WebService {
|
|
|
175
625
|
return this.decorateReplyPart(message, part);
|
|
176
626
|
}
|
|
177
627
|
async replyAttachment(threadId, messageId, partId, expires, token, signal) {
|
|
178
|
-
const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
|
|
628
|
+
const { thread, part, remainingSeconds } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
|
|
179
629
|
const connection = this.connections.get(thread.sourceId);
|
|
180
630
|
if (connection === undefined || connection.info.replyAttachments?.version !== 1) {
|
|
181
631
|
throw new WebConsoleError("reply_attachment_unavailable", "The attachment source is offline or incompatible.", 409);
|
|
@@ -191,7 +641,7 @@ export class WebService {
|
|
|
191
641
|
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
192
642
|
};
|
|
193
643
|
const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), attachment, signal);
|
|
194
|
-
return { part, response };
|
|
644
|
+
return { part, response, remainingSeconds };
|
|
195
645
|
}
|
|
196
646
|
async mcpAppResource(threadId, messageId, partId, expires, token, signal) {
|
|
197
647
|
const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
|
|
@@ -317,32 +767,71 @@ export class WebService {
|
|
|
317
767
|
return result;
|
|
318
768
|
}
|
|
319
769
|
patchThread(id, patch) {
|
|
770
|
+
if (patch.ifRunConfigUnset === true) {
|
|
771
|
+
// Compare-and-set for the console's one-time adoption of a browser-local
|
|
772
|
+
// override. Whoever set an override first keeps it; the loser adopts what
|
|
773
|
+
// it reads back, and no event is emitted because nothing changed.
|
|
774
|
+
//
|
|
775
|
+
// The check and the write are one `BEGIN IMMEDIATE` inside the store, not
|
|
776
|
+
// two calls from here: this request handler is synchronous, so no other
|
|
777
|
+
// HTTP request can interleave, but the process lease is held on a
|
|
778
|
+
// separate database file and does not stop a second connection to the
|
|
779
|
+
// state DB from writing between a bare read and a bare write.
|
|
780
|
+
const result = this.store.patchThreadIfRunConfigUnset(id, patch);
|
|
781
|
+
if (!result.applied)
|
|
782
|
+
return result.thread;
|
|
783
|
+
this.emitThread("thread.changed", { thread: result.thread });
|
|
784
|
+
this.emitThread("threads.changed", { thread: result.thread });
|
|
785
|
+
return result.thread;
|
|
786
|
+
}
|
|
320
787
|
const thread = this.store.patchThread(id, patch);
|
|
321
|
-
this.
|
|
322
|
-
this.
|
|
788
|
+
this.emitThread("thread.changed", { thread });
|
|
789
|
+
this.emitThread("threads.changed", { thread });
|
|
323
790
|
return thread;
|
|
324
791
|
}
|
|
325
|
-
async deleteThread(id) {
|
|
792
|
+
async deleteThread(id, options = {}) {
|
|
326
793
|
const resolved = this.store.getThread(id)?.id;
|
|
327
794
|
if (resolved === undefined)
|
|
328
795
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
329
796
|
if (this.activeTurns.has(resolved)) {
|
|
330
797
|
throw new WebConsoleError("turn_active", "Cancel the active turn before deleting this conversation.", 409);
|
|
331
798
|
}
|
|
332
|
-
const result = await this.store.deleteArchivedThread(resolved);
|
|
799
|
+
const result = await this.store.deleteArchivedThread(resolved, options);
|
|
333
800
|
if (result.orphanedFiles > 0) {
|
|
334
801
|
this.options.logger?.warn?.("Deleted a web conversation with attachment files deferred to orphan cleanup.", {
|
|
335
802
|
threadId: resolved,
|
|
336
803
|
count: result.orphanedFiles,
|
|
337
804
|
});
|
|
338
805
|
}
|
|
339
|
-
this.
|
|
340
|
-
this.
|
|
806
|
+
this.emitThread("thread.changed", { threadId: resolved, removed: true });
|
|
807
|
+
this.emitThread("threads.changed", { threadId: resolved, removed: true });
|
|
341
808
|
}
|
|
342
809
|
patchAgent(sourceId, patch) {
|
|
343
810
|
const agent = this.store.setAgentPinned(sourceId, patch.pinned);
|
|
344
|
-
|
|
345
|
-
|
|
811
|
+
// A pin says exactly what it changed. The payload-less form means "discovery
|
|
812
|
+
// saw something move", which costs every open console a bootstrap plus its
|
|
813
|
+
// skills and cron -- for one boolean the pinning tab already applied from
|
|
814
|
+
// this call's own response.
|
|
815
|
+
const payload = { sourceId: agent.sourceId, pinned: agent.pinned === true };
|
|
816
|
+
this.emit("agents.changed", undefined, payload);
|
|
817
|
+
return this.decorateProjectedCapabilities(agent);
|
|
818
|
+
}
|
|
819
|
+
setAgentRunDefaults(sourceId, input) {
|
|
820
|
+
const agent = this.store.getAgent(sourceId);
|
|
821
|
+
if (agent === undefined)
|
|
822
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
823
|
+
if (this.connections.get(sourceId) === undefined || agent.status === "offline") {
|
|
824
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. Reconnect it before saving new defaults.", 409);
|
|
825
|
+
}
|
|
826
|
+
this.validateModelAndEffort(sourceId, agent, input.model ?? undefined, input.effort ?? undefined, true);
|
|
827
|
+
const updated = this.store.setAgentRunOverride(sourceId, input);
|
|
828
|
+
this.emit("agents.changed");
|
|
829
|
+
return this.decorateProjectedCapabilities(updated);
|
|
830
|
+
}
|
|
831
|
+
clearAgentRunDefaults(sourceId) {
|
|
832
|
+
const agent = this.store.clearAgentRunOverride(sourceId);
|
|
833
|
+
this.emit("agents.changed");
|
|
834
|
+
return this.decorateProjectedCapabilities(agent);
|
|
346
835
|
}
|
|
347
836
|
agentSkills(sourceId) {
|
|
348
837
|
const agent = this.store.getAgent(sourceId);
|
|
@@ -387,7 +876,7 @@ export class WebService {
|
|
|
387
876
|
const stored = this.store.storedCronRuns(sourceId, jobId, input.limit);
|
|
388
877
|
return stored.messages === undefined
|
|
389
878
|
? stored
|
|
390
|
-
: { ...stored, messages: stored.messages.map((message) => this.
|
|
879
|
+
: { ...stored, messages: stored.messages.map((message) => this.shapeMessage(message)) };
|
|
391
880
|
}
|
|
392
881
|
const page = await connection.client.cronRuns(jobId, {
|
|
393
882
|
limit: input.limit,
|
|
@@ -396,25 +885,192 @@ export class WebService {
|
|
|
396
885
|
});
|
|
397
886
|
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, page.runs);
|
|
398
887
|
if (reconciled.changed) {
|
|
399
|
-
|
|
400
|
-
this.
|
|
401
|
-
this.emit("threads.changed", threadId);
|
|
888
|
+
this.announceReconciledMessages(reconciled);
|
|
889
|
+
this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
|
|
402
890
|
}
|
|
403
|
-
return { ...page, messages: reconciled.messages.map((message) => this.
|
|
891
|
+
return { ...page, messages: reconciled.messages.map((message) => this.shapeMessage(message)) };
|
|
404
892
|
}
|
|
405
893
|
async cronRun(sourceId, jobId, runId) {
|
|
406
894
|
const connection = this.requireCronConnection(sourceId, false);
|
|
407
895
|
const run = await connection.client.cronRun(jobId, runId, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
408
896
|
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [run]);
|
|
409
897
|
const message = reconciled.messages[0];
|
|
898
|
+
if (reconciled.changed) {
|
|
899
|
+
this.announceReconciledMessages(reconciled);
|
|
900
|
+
this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
|
|
901
|
+
}
|
|
410
902
|
if (message === undefined) {
|
|
903
|
+
if (reconciled.suppressedRunIds?.includes(runId)) {
|
|
904
|
+
throw new WebConsoleError("cron_run_not_visible", "This cron run has no visible message.", 404);
|
|
905
|
+
}
|
|
411
906
|
throw new WebConsoleError("invalid_operator_cron", "Cron detail did not reconcile a message.", 502);
|
|
412
907
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
908
|
+
return this.shapeMessage(message);
|
|
909
|
+
}
|
|
910
|
+
createCronReplyThread(sourceId, jobId, runId, input) {
|
|
911
|
+
const running = this.activeCronReplies.get(input.operationId);
|
|
912
|
+
if (running !== undefined) {
|
|
913
|
+
if (running.sourceId !== sourceId || running.jobId !== jobId || running.runId !== runId) {
|
|
914
|
+
return Promise.reject(new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409));
|
|
915
|
+
}
|
|
916
|
+
return running.promise;
|
|
917
|
+
}
|
|
918
|
+
const operation = this.createCronReplyThreadOnce(sourceId, jobId, runId, input)
|
|
919
|
+
.then((receipt) => ({ ...receipt, messages: this.shapeMessages(receipt.messages) }));
|
|
920
|
+
this.activeCronReplies.set(input.operationId, { sourceId, jobId, runId, promise: operation });
|
|
921
|
+
const release = () => {
|
|
922
|
+
if (this.activeCronReplies.get(input.operationId)?.promise === operation) {
|
|
923
|
+
this.activeCronReplies.delete(input.operationId);
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
void operation.then(release, release);
|
|
927
|
+
return operation;
|
|
928
|
+
}
|
|
929
|
+
async createCronReplyThreadOnce(sourceId, jobId, runId, input) {
|
|
930
|
+
let state = this.store.cronReplyOperation(input.operationId);
|
|
931
|
+
if (state !== undefined) {
|
|
932
|
+
this.assertCronReplyStateIdentity(state, sourceId, jobId, runId);
|
|
933
|
+
const terminal = this.cronReplyTerminalResult(state);
|
|
934
|
+
if (terminal !== undefined)
|
|
935
|
+
return terminal;
|
|
936
|
+
}
|
|
937
|
+
const candidate = state === undefined
|
|
938
|
+
? this.store.captureCronReplySnapshot(sourceId, jobId, runId, input.snapshotKind)
|
|
939
|
+
: undefined;
|
|
940
|
+
const connection = this.connections.get(sourceId);
|
|
941
|
+
if (connection === undefined) {
|
|
942
|
+
throw new WebConsoleError("cron_reply_agent_offline", "This agent is offline; Reply was not started.", 503);
|
|
943
|
+
}
|
|
944
|
+
if (connection.info.contextImport?.version !== 1
|
|
945
|
+
|| connection.info.contextImport.maxTextBytes < AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES) {
|
|
946
|
+
throw new WebConsoleError("cron_reply_unsupported", "This agent does not support canonical cron-result Reply.", 503);
|
|
947
|
+
}
|
|
948
|
+
if (state === undefined) {
|
|
949
|
+
state = this.store.reserveCronReplyOperation(input.operationId, candidate);
|
|
950
|
+
this.assertCronReplyStateIdentity(state, sourceId, jobId, runId);
|
|
951
|
+
const terminal = this.cronReplyTerminalResult(state);
|
|
952
|
+
if (terminal !== undefined)
|
|
953
|
+
return terminal;
|
|
954
|
+
if (state.kind === "pending" && state.operation.operationId !== input.operationId) {
|
|
955
|
+
throw new WebConsoleError("cron_reply_pending", "A Reply for this cron result is already pending. Retry it explicitly.", 409, { operationId: state.operation.operationId });
|
|
956
|
+
}
|
|
416
957
|
}
|
|
417
|
-
|
|
958
|
+
if (state.kind !== "reserved" && state.kind !== "pending") {
|
|
959
|
+
throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation cannot continue.", 409);
|
|
960
|
+
}
|
|
961
|
+
const reservation = state.operation;
|
|
962
|
+
if (reservation.snapshotText === undefined) {
|
|
963
|
+
throw new WebConsoleError("storage_corrupt", "Pending cron reply lost its immutable snapshot.", 500);
|
|
964
|
+
}
|
|
965
|
+
let canonicalStatus;
|
|
966
|
+
try {
|
|
967
|
+
canonicalStatus = await connection.client.recordContextImport(reservation.conversationId, reservation.snapshotText, reservation.idempotencyKey);
|
|
968
|
+
}
|
|
969
|
+
catch (error) {
|
|
970
|
+
if (error instanceof WebConsoleError
|
|
971
|
+
&& (error.code === "context_import_conflict"
|
|
972
|
+
|| error.code === "context_import_failed"
|
|
973
|
+
|| error.code === "context_import_unsupported")) {
|
|
974
|
+
const failed = this.store.failCronReplyOperation(input.operationId, typeof error.details?.reason === "string" ? error.details.reason : error.code);
|
|
975
|
+
const wonRace = this.cronReplyTerminalResult(failed);
|
|
976
|
+
if (wonRace !== undefined)
|
|
977
|
+
return wonRace;
|
|
978
|
+
throw new WebConsoleError(error.code === "context_import_conflict"
|
|
979
|
+
? "cron_reply_conflict"
|
|
980
|
+
: error.code === "context_import_unsupported"
|
|
981
|
+
? "cron_reply_unsupported"
|
|
982
|
+
: "cron_reply_failed", error.message, error.code === "context_import_conflict" ? 409 : error.code === "context_import_unsupported" ? 503 : 502);
|
|
983
|
+
}
|
|
984
|
+
throw new WebConsoleError("cron_reply_outcome_unknown", "Cron Reply may have reached the agent. Retry explicitly to resolve it.", 504, { operationId: input.operationId });
|
|
985
|
+
}
|
|
986
|
+
const completed = this.store.completeCronReplyOperation(input.operationId, canonicalStatus);
|
|
987
|
+
const receipt = this.cronReplyTerminalResult(completed);
|
|
988
|
+
if (receipt === undefined) {
|
|
989
|
+
throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation did not complete.", 409);
|
|
990
|
+
}
|
|
991
|
+
if (!receipt.duplicate) {
|
|
992
|
+
for (const message of receipt.messages) {
|
|
993
|
+
if (this.isCronReplyProvenanceMessage(message))
|
|
994
|
+
continue;
|
|
995
|
+
this.emit("message.changed", receipt.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
|
|
996
|
+
}
|
|
997
|
+
this.emitThread("threads.changed", { thread: receipt.thread });
|
|
998
|
+
this.emitThread("thread.changed", { thread: receipt.thread });
|
|
999
|
+
}
|
|
1000
|
+
return receipt;
|
|
1001
|
+
}
|
|
1002
|
+
assertCronReplyStateIdentity(state, sourceId, jobId, runId) {
|
|
1003
|
+
const identity = state.kind === "completed" ? state.receipt : state.operation;
|
|
1004
|
+
if (identity.sourceId !== sourceId || identity.jobId !== jobId || identity.runId !== runId) {
|
|
1005
|
+
throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
cronReplyTerminalResult(state) {
|
|
1009
|
+
if (state.kind === "completed")
|
|
1010
|
+
return state.receipt;
|
|
1011
|
+
if (state.kind === "tombstoned") {
|
|
1012
|
+
throw new WebConsoleError("cron_reply_gone", "This imported conversation was deleted.", 410);
|
|
1013
|
+
}
|
|
1014
|
+
if (state.kind === "failed") {
|
|
1015
|
+
throw new WebConsoleError("cron_reply_failed", "This cron Reply failed definitively; start a new Reply to try again.", 409, {
|
|
1016
|
+
...(state.operation.failureReason === undefined ? {} : { reason: state.operation.failureReason }),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return undefined;
|
|
1020
|
+
}
|
|
1021
|
+
async agentModels(sourceId, input) {
|
|
1022
|
+
const agent = this.store.getAgent(sourceId);
|
|
1023
|
+
if (agent === undefined)
|
|
1024
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
1025
|
+
const connection = this.connections.get(sourceId);
|
|
1026
|
+
if (connection === undefined)
|
|
1027
|
+
throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
|
|
1028
|
+
const generation = this.modelCatalogCache.get(sourceId)?.generation;
|
|
1029
|
+
const page = await connection.client.models({
|
|
1030
|
+
...(input.provider === undefined ? {} : { provider: input.provider }),
|
|
1031
|
+
...(input.q === undefined ? {} : { q: input.q }),
|
|
1032
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
1033
|
+
limit: input.limit,
|
|
1034
|
+
signal: AbortSignal.timeout(INFO_TIMEOUT_MS),
|
|
1035
|
+
});
|
|
1036
|
+
// Every proxied page widens the per-agent catalog cache, the only feed
|
|
1037
|
+
// (besides `modelOptions` keys) that makes tier-2 model admission possible,
|
|
1038
|
+
// and the only place the effort ladder a catalog model advertises is ever
|
|
1039
|
+
// seen -- `modelOptions` never describes it, so dropping it here is what
|
|
1040
|
+
// left effort validation with nothing to judge against.
|
|
1041
|
+
//
|
|
1042
|
+
// Admitted under the generation the request was ISSUED under, never under
|
|
1043
|
+
// whatever is current when it answers. A discovery refresh can retire this
|
|
1044
|
+
// agent's generation inside the await above, and keyed by source id alone
|
|
1045
|
+
// the reply -- fetched from a process that is gone -- was written straight
|
|
1046
|
+
// into the freshly reconciled map, where `source: "page"` overwrites
|
|
1047
|
+
// unconditionally. Generation 1's ladder then judged generation 2's turns.
|
|
1048
|
+
this.admitModelPage(sourceId, generation, page);
|
|
1049
|
+
return page;
|
|
1050
|
+
}
|
|
1051
|
+
async providerAuthStatus(sourceId) {
|
|
1052
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.providerAuthStatus(AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1053
|
+
}
|
|
1054
|
+
async startProviderAuth(sourceId, input) {
|
|
1055
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.startProviderAuth(input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1056
|
+
}
|
|
1057
|
+
async providerAuthSession(sourceId, sessionId) {
|
|
1058
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.providerAuthSession(sessionId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1059
|
+
}
|
|
1060
|
+
async submitProviderAuth(sourceId, sessionId, input) {
|
|
1061
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.submitProviderAuth(sessionId, input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1062
|
+
}
|
|
1063
|
+
async cancelProviderAuth(sourceId, sessionId) {
|
|
1064
|
+
await this.providerAuthCall(sourceId, async (connection) => await connection.client.cancelProviderAuth(sessionId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1065
|
+
}
|
|
1066
|
+
async startProviderAuthCheck(sourceId, input) {
|
|
1067
|
+
return await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.startProviderAuthCheck(input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1068
|
+
}
|
|
1069
|
+
async providerAuthCheck(sourceId, checkId) {
|
|
1070
|
+
return await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.providerAuthCheck(checkId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1071
|
+
}
|
|
1072
|
+
async cancelProviderAuthCheck(sourceId, checkId) {
|
|
1073
|
+
await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.cancelProviderAuthCheck(checkId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
418
1074
|
}
|
|
419
1075
|
async cronConfigView(sourceId) {
|
|
420
1076
|
const connection = this.requireCronConnection(sourceId, false);
|
|
@@ -426,9 +1082,8 @@ export class WebService {
|
|
|
426
1082
|
if (result.kind === "completed") {
|
|
427
1083
|
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [result.value.run]);
|
|
428
1084
|
if (reconciled.changed) {
|
|
429
|
-
|
|
430
|
-
this.
|
|
431
|
-
this.emit("threads.changed", threadId);
|
|
1085
|
+
this.announceReconciledMessages(reconciled);
|
|
1086
|
+
this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
|
|
432
1087
|
}
|
|
433
1088
|
}
|
|
434
1089
|
return result;
|
|
@@ -446,8 +1101,7 @@ export class WebService {
|
|
|
446
1101
|
throw new WebConsoleError("invalid_operator_cron", "Updated cron job disappeared.", 502);
|
|
447
1102
|
if (synced.changed) {
|
|
448
1103
|
this.emit("cron.changed", job.threadId, { sourceId, jobId });
|
|
449
|
-
this.
|
|
450
|
-
this.emit("threads.changed", job.threadId);
|
|
1104
|
+
this.emitStoredThread(job.threadId, ["thread.changed", "threads.changed"]);
|
|
451
1105
|
}
|
|
452
1106
|
return { ...result, value: { job } };
|
|
453
1107
|
}
|
|
@@ -455,10 +1109,40 @@ export class WebService {
|
|
|
455
1109
|
if (this.stopped) {
|
|
456
1110
|
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
457
1111
|
}
|
|
458
|
-
|
|
1112
|
+
// Monitor wakes are addressed to a retained thread, so they must reach the
|
|
1113
|
+
// wake-specific retry/abandon path even after discovery has removed the
|
|
1114
|
+
// source from the picker. New source-scoped deliveries still refresh before
|
|
1115
|
+
// the store decides whether the agent exists.
|
|
1116
|
+
if (input.triggerKind !== "monitor" && this.store.getAgent(input.sourceId) === undefined) {
|
|
459
1117
|
await this.refreshAgents();
|
|
1118
|
+
}
|
|
1119
|
+
if (this.stopped) {
|
|
1120
|
+
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
1121
|
+
}
|
|
1122
|
+
if (input.triggerKind === "monitor") {
|
|
1123
|
+
assertMonitorWakeAddress(input);
|
|
1124
|
+
const thread = this.store.getThread(input.threadId);
|
|
1125
|
+
if (thread === undefined || thread.sourceId !== input.sourceId) {
|
|
1126
|
+
return {
|
|
1127
|
+
duplicate: true,
|
|
1128
|
+
tombstoned: true,
|
|
1129
|
+
delivery: { delivered: false, code: "monitor_origin_mismatch", retryable: false },
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
if (thread.archivedAt !== null || thread.trigger !== undefined) {
|
|
1133
|
+
return {
|
|
1134
|
+
thread,
|
|
1135
|
+
duplicate: false,
|
|
1136
|
+
delivery: { delivered: false, code: "monitor_wake_failed", retryable: false },
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
const result = await this.deliverMonitorWake(input);
|
|
1140
|
+
return { thread, duplicate: result.duplicate, delivery: result.receipt };
|
|
1141
|
+
}
|
|
460
1142
|
if (input.triggerKind === "job") {
|
|
461
|
-
|
|
1143
|
+
// Destructured off: the card's message id is how this service addresses
|
|
1144
|
+
// the invalidation, and it is not part of the delivery result on the wire.
|
|
1145
|
+
const { messageId, ...completed } = this.store.upsertProcessJobCard({
|
|
462
1146
|
sourceId: input.sourceId,
|
|
463
1147
|
threadId: input.threadId,
|
|
464
1148
|
deliveryKey: input.deliveryKey,
|
|
@@ -466,16 +1150,24 @@ export class WebService {
|
|
|
466
1150
|
...(input.text === undefined ? {} : { responseText: input.text }),
|
|
467
1151
|
...(input.parts === undefined ? {} : { replyParts: input.parts }),
|
|
468
1152
|
});
|
|
469
|
-
|
|
1153
|
+
// Addressed, not searched. Scanning a page of the conversation meant a job
|
|
1154
|
+
// that finished behind thirty later messages emitted no invalidation at
|
|
1155
|
+
// all, and its card sat at "running" until something else forced a read.
|
|
1156
|
+
const message = this.store.getMessage(messageId);
|
|
470
1157
|
if (!completed.duplicate && message !== undefined) {
|
|
471
1158
|
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
472
1159
|
}
|
|
473
|
-
|
|
474
|
-
|
|
1160
|
+
// Read back rather than trusted: the shared notification result leaves the
|
|
1161
|
+
// conversation optional, and an event that carries `undefined` is exactly
|
|
1162
|
+
// the bare event this normalisation exists to remove.
|
|
1163
|
+
this.emitStoredThread(input.threadId, ["threads.changed", "thread.changed"]);
|
|
475
1164
|
if (input.wakePrompt === undefined)
|
|
476
1165
|
return completed;
|
|
477
1166
|
if (this.connections.get(input.sourceId) === undefined)
|
|
478
1167
|
await this.refreshAgents();
|
|
1168
|
+
if (this.stopped) {
|
|
1169
|
+
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
1170
|
+
}
|
|
479
1171
|
const delivery = await this.deliverProcessJobWake(input);
|
|
480
1172
|
return { ...completed, delivery };
|
|
481
1173
|
}
|
|
@@ -515,28 +1207,148 @@ export class WebService {
|
|
|
515
1207
|
async startTurn(threadId, input) {
|
|
516
1208
|
const text = input.text ?? "";
|
|
517
1209
|
const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
|
|
518
|
-
|
|
519
|
-
throw new WebConsoleError("turn_text_too_large", `The message and quote may contain at most ${WEB_MAX_TURN_TEXT_CHARACTERS} characters after formatting.`, 413);
|
|
520
|
-
}
|
|
1210
|
+
assertTurnTextWithinLimit(operatorText);
|
|
521
1211
|
const attachmentIds = input.attachmentIds ?? [];
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
1212
|
+
const selection = this.resolveTurnSelection(threadId, input.model, input.effort);
|
|
1213
|
+
const { thread, agent, model, effort, requestedModel, requestedEffort } = selection;
|
|
525
1214
|
threadId = thread.id;
|
|
526
|
-
const agent = this.store.getAgent(thread.sourceId);
|
|
527
1215
|
const connection = this.connections.get(thread.sourceId);
|
|
528
1216
|
if (thread.trigger?.kind === "cron")
|
|
529
1217
|
throw cronChannelReadOnlyError();
|
|
530
|
-
if (
|
|
1218
|
+
if (connection === undefined || !thread.canSend) {
|
|
531
1219
|
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
532
1220
|
}
|
|
533
|
-
|
|
534
|
-
|
|
1221
|
+
const started = this.store.beginTurn({
|
|
1222
|
+
threadId,
|
|
1223
|
+
text,
|
|
1224
|
+
attachmentIds,
|
|
1225
|
+
...(input.quote === undefined ? {} : { quote: input.quote }),
|
|
1226
|
+
...(model === undefined ? {} : { model }),
|
|
1227
|
+
...(effort === undefined ? {} : { effort }),
|
|
1228
|
+
...(requestedModel === undefined ? {} : { requestedModel }),
|
|
1229
|
+
...(requestedEffort === undefined ? {} : { requestedEffort }),
|
|
1230
|
+
});
|
|
535
1231
|
this.launchTurn(started, connection.client, operatorText);
|
|
1232
|
+
// The operator's own row, inserted by `beginTurn` and announced by nothing
|
|
1233
|
+
// else. A console that did not issue this turn holds neither it nor the
|
|
1234
|
+
// assistant row the deltas are about to describe, and it no longer answers
|
|
1235
|
+
// a conversation summary by re-reading the transcript.
|
|
1236
|
+
this.emit("message.changed", threadId, {
|
|
1237
|
+
messageId: started.userMessageId,
|
|
1238
|
+
updatedAt: started.thread.updatedAt,
|
|
1239
|
+
});
|
|
536
1240
|
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
537
|
-
this.
|
|
1241
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
538
1242
|
return { thread: started.thread, turn: started.thread.runState };
|
|
539
1243
|
}
|
|
1244
|
+
submit(threadId, input) {
|
|
1245
|
+
if (this.stopped)
|
|
1246
|
+
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
1247
|
+
const thread = this.store.getThread(threadId);
|
|
1248
|
+
if (thread === undefined)
|
|
1249
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
1250
|
+
threadId = thread.id;
|
|
1251
|
+
const text = input.text ?? "";
|
|
1252
|
+
const attachmentIds = input.attachmentIds ?? [];
|
|
1253
|
+
const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
|
|
1254
|
+
const payloadSha256 = createHash("sha256").update(JSON.stringify({
|
|
1255
|
+
text,
|
|
1256
|
+
quote: input.quote ?? null,
|
|
1257
|
+
attachmentIds,
|
|
1258
|
+
model: input.model ?? null,
|
|
1259
|
+
effort: input.effort ?? null,
|
|
1260
|
+
})).digest("hex");
|
|
1261
|
+
const existing = this.store.webSubmission(threadId, input.submissionId);
|
|
1262
|
+
if (existing !== undefined) {
|
|
1263
|
+
if (existing.payloadSha256 !== payloadSha256) {
|
|
1264
|
+
throw new WebConsoleError("submission_conflict", "Submission id was already used for different content.", 409);
|
|
1265
|
+
}
|
|
1266
|
+
return this.submissionReceipt(existing);
|
|
1267
|
+
}
|
|
1268
|
+
assertTurnTextWithinLimit(operatorText);
|
|
1269
|
+
if (thread.trigger?.kind === "cron")
|
|
1270
|
+
throw cronChannelReadOnlyError();
|
|
1271
|
+
const connection = this.connections.get(thread.sourceId);
|
|
1272
|
+
if (connection === undefined || !thread.canSend) {
|
|
1273
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
1274
|
+
}
|
|
1275
|
+
let started;
|
|
1276
|
+
let reserved;
|
|
1277
|
+
const activeTurnId = this.store.activeTurn(threadId)?.id;
|
|
1278
|
+
const activeTarget = activeTurnId === undefined ? undefined : this.activeTurns.get(threadId);
|
|
1279
|
+
const ownsActiveTarget = activeTarget?.turnId === activeTurnId;
|
|
1280
|
+
const claimed = this.store.claimWebSubmission({
|
|
1281
|
+
threadId,
|
|
1282
|
+
submissionId: input.submissionId,
|
|
1283
|
+
payloadSha256,
|
|
1284
|
+
create: () => {
|
|
1285
|
+
if (activeTurnId !== undefined && attachmentIds.length > 0) {
|
|
1286
|
+
return { outcome: "rejected", reason: "active_attachments_unsupported" };
|
|
1287
|
+
}
|
|
1288
|
+
if (activeTurnId !== undefined) {
|
|
1289
|
+
reserved = this.store.reserveLiveInput(threadId, text, input.quote, operatorText);
|
|
1290
|
+
if (!connection.info.supportsLiveInputTargeting || !ownsActiveTarget) {
|
|
1291
|
+
const reason = connection.info.supportsLiveInputTargeting
|
|
1292
|
+
? "closed_before_dispatch"
|
|
1293
|
+
: "unsupported_targeting";
|
|
1294
|
+
this.store.queueLiveInput(reserved.input.id, reason);
|
|
1295
|
+
return {
|
|
1296
|
+
outcome: "live-input",
|
|
1297
|
+
reason,
|
|
1298
|
+
messageId: reserved.message.id,
|
|
1299
|
+
inputId: reserved.input.id,
|
|
1300
|
+
turnId: activeTurnId,
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
outcome: "live-input",
|
|
1305
|
+
messageId: reserved.message.id,
|
|
1306
|
+
inputId: reserved.input.id,
|
|
1307
|
+
turnId: activeTurnId,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
const { model, effort, requestedModel, requestedEffort } = this.resolveTurnSelection(threadId, input.model, input.effort);
|
|
1311
|
+
started = this.store.beginTurn({
|
|
1312
|
+
threadId,
|
|
1313
|
+
text,
|
|
1314
|
+
attachmentIds,
|
|
1315
|
+
...(input.quote === undefined ? {} : { quote: input.quote }),
|
|
1316
|
+
...(model === undefined ? {} : { model }),
|
|
1317
|
+
...(effort === undefined ? {} : { effort }),
|
|
1318
|
+
...(requestedModel === undefined ? {} : { requestedModel }),
|
|
1319
|
+
...(requestedEffort === undefined ? {} : { requestedEffort }),
|
|
1320
|
+
});
|
|
1321
|
+
return {
|
|
1322
|
+
outcome: "turn",
|
|
1323
|
+
messageId: started.userMessageId,
|
|
1324
|
+
turnId: started.turnId,
|
|
1325
|
+
};
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
if (claimed.created && started !== undefined) {
|
|
1329
|
+
this.launchTurn(started, connection.client, operatorText);
|
|
1330
|
+
this.emit("message.changed", threadId, { messageId: started.userMessageId, updatedAt: started.thread.updatedAt });
|
|
1331
|
+
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
1332
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
1333
|
+
}
|
|
1334
|
+
else if (claimed.created && reserved !== undefined) {
|
|
1335
|
+
this.emit("message.changed", threadId, { messageId: reserved.message.id, updatedAt: reserved.message.updatedAt });
|
|
1336
|
+
this.emitThread("threads.changed", { thread: reserved.thread });
|
|
1337
|
+
if (claimed.submission.reason !== undefined || activeTarget === undefined) {
|
|
1338
|
+
void this.drainQueuedLiveInputs(threadId);
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
void this.dispatchTargetedSubmission(claimed.submission, activeTarget);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return this.submissionReceipt(claimed.submission);
|
|
1345
|
+
}
|
|
1346
|
+
submission(threadId, submissionId) {
|
|
1347
|
+
const stored = this.store.webSubmission(threadId, submissionId);
|
|
1348
|
+
if (stored === undefined)
|
|
1349
|
+
throw new WebConsoleError("submission_not_found", "Submission not found.", 404);
|
|
1350
|
+
return this.submissionReceipt(stored);
|
|
1351
|
+
}
|
|
540
1352
|
submitLiveInput(threadId, text) {
|
|
541
1353
|
if (this.stopped) {
|
|
542
1354
|
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
@@ -549,13 +1361,21 @@ export class WebService {
|
|
|
549
1361
|
const active = this.activeTurns.get(threadId);
|
|
550
1362
|
const reserved = this.store.reserveLiveInput(threadId, text);
|
|
551
1363
|
this.emit("message.changed", threadId, { messageId: reserved.message.id, updatedAt: reserved.message.updatedAt });
|
|
552
|
-
this.
|
|
1364
|
+
this.emitThread("threads.changed", { thread: reserved.thread });
|
|
553
1365
|
if (!reserved.offered || active === undefined || connection === undefined || !connection.info.supportsLiveInput) {
|
|
554
1366
|
const queued = reserved.offered ? this.store.queueLiveInput(reserved.input.id) ?? reserved.message : reserved.message;
|
|
555
1367
|
this.emit("message.changed", threadId, { messageId: queued.id, updatedAt: queued.updatedAt });
|
|
556
1368
|
void this.drainQueuedLiveInputs(threadId);
|
|
557
1369
|
return { message: queued, disposition: "queued" };
|
|
558
1370
|
}
|
|
1371
|
+
if (!this.store.markLiveInputDispatchStarted(reserved.input.id, active.turnId)) {
|
|
1372
|
+
// A concurrent terminal transition won before the request boundary. Do
|
|
1373
|
+
// not dispatch and do not recreate a fallback from stale in-memory state.
|
|
1374
|
+
return {
|
|
1375
|
+
message: this.store.getMessage(reserved.message.id) ?? reserved.message,
|
|
1376
|
+
disposition: "pending",
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
559
1379
|
const controller = new AbortController();
|
|
560
1380
|
const completion = this.deliverLiveInput(reserved.input.id, threadId, active.client, controller, {
|
|
561
1381
|
conversationId: `web:${threadId}`,
|
|
@@ -678,6 +1498,17 @@ export class WebService {
|
|
|
678
1498
|
readyEvent() {
|
|
679
1499
|
return this.createEvent("ready", undefined, { version: WEB_API_VERSION });
|
|
680
1500
|
}
|
|
1501
|
+
/**
|
|
1502
|
+
* The id a conversation is known by NOW, following whatever superseded it.
|
|
1503
|
+
*
|
|
1504
|
+
* Every read resolves redirects on its way through the store; the event
|
|
1505
|
+
* stream matches its subscription by string equality against the id events
|
|
1506
|
+
* carry, so it has to ask for the same resolution explicitly. An id nothing
|
|
1507
|
+
* superseded resolves to itself, so this is safe for any string.
|
|
1508
|
+
*/
|
|
1509
|
+
resolveThreadId(id) {
|
|
1510
|
+
return this.store.resolveThreadId(id);
|
|
1511
|
+
}
|
|
681
1512
|
refreshAgents() {
|
|
682
1513
|
if (this.stopped)
|
|
683
1514
|
return Promise.resolve();
|
|
@@ -709,7 +1540,7 @@ export class WebService {
|
|
|
709
1540
|
const active = [...this.activeTurns.values()];
|
|
710
1541
|
const activeLiveInputs = [...this.activeLiveInputs.entries()];
|
|
711
1542
|
const activeNotifications = [...this.activeNotifications.values()];
|
|
712
|
-
const
|
|
1543
|
+
const activeHostWakes = [...this.activeHostWakes.values()];
|
|
713
1544
|
const trackedIds = new Set(active.map((turn) => turn.turnId));
|
|
714
1545
|
for (const turnId of this.store.listActiveTurnIds()) {
|
|
715
1546
|
if (!trackedIds.has(turnId))
|
|
@@ -720,12 +1551,15 @@ export class WebService {
|
|
|
720
1551
|
turn.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
|
|
721
1552
|
}
|
|
722
1553
|
for (const [id, input] of activeLiveInputs) {
|
|
723
|
-
|
|
1554
|
+
// Dispatch has already crossed the durable marker boundary. Seal it as
|
|
1555
|
+
// uncertain before abort so a crash at any later shutdown cut-point can
|
|
1556
|
+
// never recover it into the automatic next-turn queue.
|
|
1557
|
+
this.store.markLiveInputUncertain(id);
|
|
724
1558
|
input.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
|
|
725
1559
|
}
|
|
726
1560
|
await Promise.allSettled(active.map((turn) => turn.completion));
|
|
727
1561
|
await Promise.allSettled(activeLiveInputs.map(([, input]) => input.completion));
|
|
728
|
-
await Promise.allSettled(
|
|
1562
|
+
await Promise.allSettled(activeHostWakes);
|
|
729
1563
|
await Promise.allSettled(activeNotifications);
|
|
730
1564
|
await Promise.allSettled(askWatches.map((watch) => watch.promise));
|
|
731
1565
|
await this.pushDispatcher.stopAndDrain(5_000);
|
|
@@ -737,10 +1571,9 @@ export class WebService {
|
|
|
737
1571
|
this.store.close();
|
|
738
1572
|
await this.lease.release();
|
|
739
1573
|
}
|
|
740
|
-
async runTurn(started, client, controller, operatorText,
|
|
1574
|
+
async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey, onAdmitted) {
|
|
741
1575
|
const coalescer = new StreamFrameCoalescer(async (frames) => {
|
|
742
|
-
|
|
743
|
-
this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
|
|
1576
|
+
this.emitMessageWrite(started.thread.id, this.store.applyStreamFrames(started.turnId, frames));
|
|
744
1577
|
}, (error) => controller.abort(error));
|
|
745
1578
|
let releaseAttachmentBudget;
|
|
746
1579
|
try {
|
|
@@ -761,24 +1594,32 @@ export class WebService {
|
|
|
761
1594
|
threadId: started.thread.id,
|
|
762
1595
|
turnId: started.turnId,
|
|
763
1596
|
...modelMetadata,
|
|
764
|
-
...(
|
|
1597
|
+
...(hostWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
|
|
765
1598
|
? { conversationTitle: { schema: 1, writable: true } }
|
|
766
1599
|
: {}),
|
|
767
1600
|
},
|
|
768
1601
|
tui: modelMetadata,
|
|
769
1602
|
},
|
|
770
|
-
...(
|
|
1603
|
+
...(hostWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey: hostWakeDeliveryKey }),
|
|
771
1604
|
onFrame: (frame) => {
|
|
772
1605
|
this.observeAskUserFrame(started.thread.id, started.turnId, frame);
|
|
773
1606
|
this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
|
|
774
1607
|
coalescer.push(frame);
|
|
775
1608
|
},
|
|
1609
|
+
...(onAdmitted === undefined ? {} : { onAdmitted }),
|
|
776
1610
|
});
|
|
777
1611
|
await coalescer.flush();
|
|
778
|
-
const
|
|
1612
|
+
const silentMonitorWake = hostWakeDeliveryKey?.startsWith("monitor:") === true
|
|
1613
|
+
&& (response.finalText === undefined || response.finalText.length === 0)
|
|
1614
|
+
&& (response.parts === undefined || response.parts.length === 0);
|
|
1615
|
+
const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts, {
|
|
1616
|
+
suppressResponsePush: silentMonitorWake,
|
|
1617
|
+
...(hostWakeDeliveryKey === undefined ? {} : { monitorWakeDeliveryKey: hostWakeDeliveryKey }),
|
|
1618
|
+
});
|
|
1619
|
+
this.emitMessageWrite(started.thread.id, detail.write);
|
|
779
1620
|
this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
|
|
780
|
-
this.
|
|
781
|
-
this.
|
|
1621
|
+
this.emitThread("thread.changed", { thread: detail.thread });
|
|
1622
|
+
this.emitThread("threads.changed", { thread: detail.thread });
|
|
782
1623
|
this.announcePushEvent(`turn:${started.turnId}:terminal`);
|
|
783
1624
|
// Detached: the turn is already finished and reported, and keeping a copy
|
|
784
1625
|
// must neither delay nor fail it. The agent is still connected here, which
|
|
@@ -802,9 +1643,10 @@ export class WebService {
|
|
|
802
1643
|
...(code === undefined ? {} : { code }),
|
|
803
1644
|
cancelled,
|
|
804
1645
|
});
|
|
1646
|
+
this.emitMessageWrite(started.thread.id, detail.write);
|
|
805
1647
|
this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
|
|
806
|
-
this.
|
|
807
|
-
this.
|
|
1648
|
+
this.emitThread("thread.changed", { thread: detail.thread });
|
|
1649
|
+
this.emitThread("threads.changed", { thread: detail.thread });
|
|
808
1650
|
this.announcePushEvent(`turn:${started.turnId}:terminal`);
|
|
809
1651
|
}
|
|
810
1652
|
finally {
|
|
@@ -812,19 +1654,80 @@ export class WebService {
|
|
|
812
1654
|
coalescer.close();
|
|
813
1655
|
}
|
|
814
1656
|
}
|
|
815
|
-
launchTurn(started, client, operatorText,
|
|
1657
|
+
launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
|
|
816
1658
|
const threadId = started.thread.id;
|
|
817
1659
|
const controller = new AbortController();
|
|
818
|
-
|
|
1660
|
+
let resolveAdmitted;
|
|
1661
|
+
const admitted = new Promise((resolve) => { resolveAdmitted = resolve; });
|
|
1662
|
+
const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey, () => { resolveAdmitted(true); }).finally(() => {
|
|
1663
|
+
// Inert once admission already resolved; the turn settled without the
|
|
1664
|
+
// operator ever returning a stream when it did not.
|
|
1665
|
+
resolveAdmitted(false);
|
|
819
1666
|
const active = this.activeTurns.get(threadId);
|
|
820
1667
|
if (active?.turnId === started.turnId)
|
|
821
1668
|
this.activeTurns.delete(threadId);
|
|
822
|
-
if (!this.stopped && !this.
|
|
1669
|
+
if (!this.stopped && !this.hostWakeReservations.has(threadId)) {
|
|
823
1670
|
void this.drainQueuedLiveInputs(threadId);
|
|
824
1671
|
}
|
|
825
1672
|
});
|
|
826
|
-
this.activeTurns.set(threadId, {
|
|
827
|
-
|
|
1673
|
+
this.activeTurns.set(threadId, {
|
|
1674
|
+
turnId: started.turnId,
|
|
1675
|
+
controller,
|
|
1676
|
+
client,
|
|
1677
|
+
completion,
|
|
1678
|
+
admitted,
|
|
1679
|
+
resolveAdmitted,
|
|
1680
|
+
});
|
|
1681
|
+
return { completion, admitted };
|
|
1682
|
+
}
|
|
1683
|
+
submissionReceipt(submission) {
|
|
1684
|
+
const message = submission.messageId === undefined ? undefined : this.store.getMessage(submission.messageId);
|
|
1685
|
+
const thread = this.store.getThread(submission.threadId);
|
|
1686
|
+
return {
|
|
1687
|
+
submissionId: submission.submissionId,
|
|
1688
|
+
threadId: submission.threadId,
|
|
1689
|
+
outcome: submission.outcome,
|
|
1690
|
+
...(submission.reason === undefined ? {} : { reason: submission.reason }),
|
|
1691
|
+
...(submission.messageId === undefined ? {} : { messageId: submission.messageId }),
|
|
1692
|
+
...(submission.turnId === undefined ? {} : { turnId: submission.turnId }),
|
|
1693
|
+
...(message === undefined ? {} : { message }),
|
|
1694
|
+
...(submission.turnId === undefined || thread?.runState.id !== submission.turnId
|
|
1695
|
+
? {}
|
|
1696
|
+
: { turn: thread.runState }),
|
|
1697
|
+
...(submission.outcome !== "live-input"
|
|
1698
|
+
? {}
|
|
1699
|
+
: message?.liveInputStatus === "queued"
|
|
1700
|
+
? { disposition: "queued" }
|
|
1701
|
+
: message?.liveInputStatus === "pending"
|
|
1702
|
+
? { disposition: "pending" }
|
|
1703
|
+
: {}),
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
async dispatchTargetedSubmission(submission, active) {
|
|
1707
|
+
if (submission.inputId === undefined || submission.messageId === undefined || submission.turnId === undefined)
|
|
1708
|
+
return;
|
|
1709
|
+
await active.admitted;
|
|
1710
|
+
if (this.activeTurns.get(submission.threadId) !== active
|
|
1711
|
+
|| this.store.activeTurn(submission.threadId)?.id !== submission.turnId) {
|
|
1712
|
+
const queued = this.store.queueLiveInput(submission.inputId, "closed_before_dispatch");
|
|
1713
|
+
if (queued !== undefined) {
|
|
1714
|
+
this.emit("message.changed", submission.threadId, { messageId: queued.id, updatedAt: queued.updatedAt });
|
|
1715
|
+
void this.drainQueuedLiveInputs(submission.threadId);
|
|
1716
|
+
}
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const input = this.store.storedLiveInput(submission.inputId);
|
|
1720
|
+
if (input === undefined || !this.store.markLiveInputDispatchStarted(submission.inputId, submission.turnId))
|
|
1721
|
+
return;
|
|
1722
|
+
const controller = new AbortController();
|
|
1723
|
+
const completion = this.deliverLiveInput(submission.inputId, submission.threadId, active.client, controller, {
|
|
1724
|
+
conversationId: `web:${submission.threadId}`,
|
|
1725
|
+
id: submission.inputId,
|
|
1726
|
+
text: input.text,
|
|
1727
|
+
receivedAt: input.createdAt,
|
|
1728
|
+
targetTurnId: submission.turnId,
|
|
1729
|
+
}).finally(() => this.activeLiveInputs.delete(submission.inputId));
|
|
1730
|
+
this.activeLiveInputs.set(submission.inputId, { threadId: submission.threadId, controller, completion });
|
|
828
1731
|
}
|
|
829
1732
|
async deliverLiveInput(id, threadId, client, controller, input) {
|
|
830
1733
|
let queued = false;
|
|
@@ -837,16 +1740,22 @@ export class WebService {
|
|
|
837
1740
|
else if (result.status === "discarded") {
|
|
838
1741
|
changedMessage = this.store.cancelLiveInput(id);
|
|
839
1742
|
}
|
|
840
|
-
else {
|
|
841
|
-
changedMessage = this.store.queueLiveInput(id);
|
|
1743
|
+
else if (result.status === "requeue") {
|
|
1744
|
+
changedMessage = this.store.queueLiveInput(id, `mailbox_${result.reason}`);
|
|
842
1745
|
queued = changedMessage !== undefined;
|
|
843
1746
|
}
|
|
1747
|
+
else if (result.status === "unavailable") {
|
|
1748
|
+
changedMessage = this.store.queueLiveInput(id, `operator_${result.reason}`);
|
|
1749
|
+
queued = changedMessage !== undefined;
|
|
1750
|
+
}
|
|
1751
|
+
else {
|
|
1752
|
+
changedMessage = this.store.markLiveInputUncertain(id);
|
|
1753
|
+
}
|
|
844
1754
|
}
|
|
845
1755
|
catch (error) {
|
|
846
|
-
changedMessage = this.store.
|
|
847
|
-
queued = changedMessage !== undefined;
|
|
1756
|
+
changedMessage = this.store.markLiveInputUncertain(id);
|
|
848
1757
|
if (!controller.signal.aborted) {
|
|
849
|
-
this.options.logger?.debug?.("Web live-input delivery
|
|
1758
|
+
this.options.logger?.debug?.("Web live-input delivery outcome is uncertain; automatic fallback is suppressed.", {
|
|
850
1759
|
threadId,
|
|
851
1760
|
error: errorMessage(error),
|
|
852
1761
|
});
|
|
@@ -858,14 +1767,14 @@ export class WebService {
|
|
|
858
1767
|
updatedAt: changedMessage.updatedAt,
|
|
859
1768
|
});
|
|
860
1769
|
}
|
|
861
|
-
this.
|
|
1770
|
+
this.emitStoredThread(threadId, ["threads.changed"]);
|
|
862
1771
|
if (queued && !this.stopped)
|
|
863
1772
|
await this.drainQueuedLiveInputs(threadId);
|
|
864
1773
|
}
|
|
865
1774
|
async drainQueuedLiveInputs(threadId) {
|
|
866
1775
|
if (this.stopped
|
|
867
1776
|
|| this.activeTurns.has(threadId)
|
|
868
|
-
|| this.
|
|
1777
|
+
|| this.hostWakeReservations.has(threadId)
|
|
869
1778
|
|| this.drainingLiveInputThreads.has(threadId))
|
|
870
1779
|
return;
|
|
871
1780
|
this.drainingLiveInputThreads.add(threadId);
|
|
@@ -880,20 +1789,28 @@ export class WebService {
|
|
|
880
1789
|
if (started === undefined)
|
|
881
1790
|
return;
|
|
882
1791
|
this.launchTurn(started, connection.client, started.text);
|
|
1792
|
+
// BOTH rows. `promoteNextQueuedLiveInput` rewrites the queued operator
|
|
1793
|
+
// message (its live-input status becomes "applied") as well as opening
|
|
1794
|
+
// the assistant row, and a console that heard only about the second was
|
|
1795
|
+
// left showing a steer that still reads "queued".
|
|
1796
|
+
this.emit("message.changed", threadId, {
|
|
1797
|
+
messageId: started.userMessageId,
|
|
1798
|
+
updatedAt: started.thread.updatedAt,
|
|
1799
|
+
});
|
|
883
1800
|
this.emit("message.changed", threadId, {
|
|
884
1801
|
messageId: started.assistantMessageId,
|
|
885
1802
|
updatedAt: started.thread.updatedAt,
|
|
886
1803
|
});
|
|
887
1804
|
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
888
|
-
this.
|
|
1805
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
889
1806
|
}
|
|
890
1807
|
finally {
|
|
891
1808
|
this.drainingLiveInputThreads.delete(threadId);
|
|
892
1809
|
}
|
|
893
1810
|
}
|
|
894
1811
|
async deliverProcessJobWake(input) {
|
|
895
|
-
const activeKey = `${input.sourceId}\0${input.
|
|
896
|
-
const existing = this.
|
|
1812
|
+
const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
|
|
1813
|
+
const existing = this.activeHostWakes.get(activeKey);
|
|
897
1814
|
if (existing !== undefined)
|
|
898
1815
|
return await existing;
|
|
899
1816
|
const reservation = this.store.reserveProcessJobWake({
|
|
@@ -913,8 +1830,8 @@ export class WebService {
|
|
|
913
1830
|
ambiguous: true,
|
|
914
1831
|
};
|
|
915
1832
|
}
|
|
916
|
-
this.
|
|
917
|
-
const previous = this.
|
|
1833
|
+
this.retainHostWakeReservation(input.threadId);
|
|
1834
|
+
const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
|
|
918
1835
|
const delivery = previous.catch(() => undefined).then(async () => {
|
|
919
1836
|
const connection = this.connections.get(input.sourceId);
|
|
920
1837
|
if (connection === undefined) {
|
|
@@ -928,6 +1845,7 @@ export class WebService {
|
|
|
928
1845
|
const active = this.activeTurns.get(input.threadId);
|
|
929
1846
|
if (active !== undefined && connection.info.supportsLiveInput) {
|
|
930
1847
|
try {
|
|
1848
|
+
this.store.associateProcessJobWakeTurn(input.deliveryKey, active.turnId);
|
|
931
1849
|
const settlement = await active.client.liveInput({
|
|
932
1850
|
conversationId: `web:${input.threadId}`,
|
|
933
1851
|
id: input.deliveryKey,
|
|
@@ -937,16 +1855,33 @@ export class WebService {
|
|
|
937
1855
|
signal: AbortSignal.timeout(10 * 60 * 1_000),
|
|
938
1856
|
});
|
|
939
1857
|
if (settlement.status === "applied") {
|
|
940
|
-
this.store.completeProcessJobWake({
|
|
1858
|
+
const message = this.store.completeProcessJobWake({
|
|
941
1859
|
sourceId: input.sourceId,
|
|
942
1860
|
jobId: input.processJob.jobId,
|
|
943
1861
|
deliveryKey: input.deliveryKey,
|
|
944
1862
|
disposition: "steered",
|
|
1863
|
+
turnId: active.turnId,
|
|
945
1864
|
});
|
|
1865
|
+
if (message !== undefined) {
|
|
1866
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
1867
|
+
}
|
|
946
1868
|
return { delivered: true, disposition: "steered" };
|
|
947
1869
|
}
|
|
1870
|
+
if (settlement.status !== "requeue" && settlement.status !== "unavailable") {
|
|
1871
|
+
this.store.releaseProcessJobWakeTurn(input.deliveryKey, active.turnId);
|
|
1872
|
+
return {
|
|
1873
|
+
delivered: false,
|
|
1874
|
+
code: "process_job_wake_ambiguous",
|
|
1875
|
+
retryable: false,
|
|
1876
|
+
ambiguous: true,
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
this.store.associateProcessJobWakeTurn(input.deliveryKey, active.turnId, false);
|
|
948
1880
|
}
|
|
949
1881
|
catch (error) {
|
|
1882
|
+
// Receipt uncertainty forbids replay, but is not authority to silence
|
|
1883
|
+
// the ordinary answer from the active turn indefinitely.
|
|
1884
|
+
this.store.releaseProcessJobWakeTurn(input.deliveryKey, active.turnId);
|
|
950
1885
|
this.options.logger?.warn?.("Web process-job steering outcome is unknown; automatic fallback is suppressed.", {
|
|
951
1886
|
threadId: input.threadId,
|
|
952
1887
|
error: errorMessage(error),
|
|
@@ -959,8 +1894,41 @@ export class WebService {
|
|
|
959
1894
|
};
|
|
960
1895
|
}
|
|
961
1896
|
}
|
|
962
|
-
if (active !== undefined)
|
|
963
|
-
|
|
1897
|
+
if (active !== undefined) {
|
|
1898
|
+
try {
|
|
1899
|
+
await active.completion;
|
|
1900
|
+
}
|
|
1901
|
+
catch {
|
|
1902
|
+
// No live-input request was sent, or the active run explicitly said
|
|
1903
|
+
// requeue/unavailable before this wait. This wake therefore has not
|
|
1904
|
+
// crossed an operator boundary and is safe to release only when the
|
|
1905
|
+
// store proves that it deleted the exact accepted reservation.
|
|
1906
|
+
let abandoned = false;
|
|
1907
|
+
try {
|
|
1908
|
+
abandoned = this.store.abandonProcessJobWake({
|
|
1909
|
+
sourceId: input.sourceId,
|
|
1910
|
+
jobId: input.processJob.jobId,
|
|
1911
|
+
deliveryKey: input.deliveryKey,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
catch {
|
|
1915
|
+
// The accepted claim may remain. Preserve ambiguity and no-replay.
|
|
1916
|
+
}
|
|
1917
|
+
if (!abandoned) {
|
|
1918
|
+
return {
|
|
1919
|
+
delivered: false,
|
|
1920
|
+
code: "process_job_wake_ambiguous",
|
|
1921
|
+
retryable: false,
|
|
1922
|
+
ambiguous: true,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
return {
|
|
1926
|
+
delivered: false,
|
|
1927
|
+
code: "process_job_wake_failed",
|
|
1928
|
+
retryable: false,
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
964
1932
|
if (this.stopped) {
|
|
965
1933
|
this.store.abandonProcessJobWake({
|
|
966
1934
|
sourceId: input.sourceId,
|
|
@@ -980,9 +1948,19 @@ export class WebService {
|
|
|
980
1948
|
}
|
|
981
1949
|
let started;
|
|
982
1950
|
try {
|
|
1951
|
+
const selection = this.resolveTurnSelection(input.threadId);
|
|
983
1952
|
started = this.store.beginAssistantTurn({
|
|
984
1953
|
threadId: input.threadId,
|
|
985
1954
|
prompt: input.wakePrompt,
|
|
1955
|
+
processJobWake: {
|
|
1956
|
+
jobId: input.processJob.jobId,
|
|
1957
|
+
deliveryKey: input.deliveryKey,
|
|
1958
|
+
disposition: "follow_up",
|
|
1959
|
+
},
|
|
1960
|
+
...(selection.model === undefined ? {} : { model: selection.model }),
|
|
1961
|
+
...(selection.effort === undefined ? {} : { effort: selection.effort }),
|
|
1962
|
+
...(selection.requestedModel === undefined ? {} : { requestedModel: selection.requestedModel }),
|
|
1963
|
+
...(selection.requestedEffort === undefined ? {} : { requestedEffort: selection.requestedEffort }),
|
|
986
1964
|
});
|
|
987
1965
|
}
|
|
988
1966
|
catch (error) {
|
|
@@ -997,56 +1975,277 @@ export class WebService {
|
|
|
997
1975
|
retryable: false,
|
|
998
1976
|
};
|
|
999
1977
|
}
|
|
1000
|
-
const completion = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
1978
|
+
const { completion, admitted } = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
1979
|
+
// Receipt ownership moves to the durable turn below. The turn remains
|
|
1980
|
+
// owned by `activeTurns`, but its completion is no longer part of the
|
|
1981
|
+
// notification request and an unexpected terminal-store failure must not
|
|
1982
|
+
// become an unhandled rejection after the receipt has returned. Attached
|
|
1983
|
+
// before the admission wait below, so a rejection in that window cannot
|
|
1984
|
+
// escape either.
|
|
1985
|
+
void completion.catch((error) => {
|
|
1986
|
+
try {
|
|
1987
|
+
this.options.logger?.error?.("Web process-job follow-up turn settlement failed after admission.", {
|
|
1988
|
+
threadId: input.threadId,
|
|
1989
|
+
turnId: started.turnId,
|
|
1990
|
+
errorCode: errorCode(error) ?? "unknown",
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
catch {
|
|
1994
|
+
// A caller-provided logger cannot be allowed to re-detach the failure.
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
// Announced before the wait: this row exists either way, and a turn that
|
|
1998
|
+
// never reaches the operator still has to be visible as the failure it is.
|
|
1001
1999
|
this.emit("message.changed", input.threadId, {
|
|
1002
2000
|
messageId: started.assistantMessageId,
|
|
1003
2001
|
updatedAt: started.thread.updatedAt,
|
|
1004
2002
|
});
|
|
1005
2003
|
this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
|
|
1006
|
-
this.
|
|
1007
|
-
|
|
1008
|
-
|
|
2004
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
2005
|
+
// The wake's host-owned capability — chain depth and remaining background
|
|
2006
|
+
// starts — is bound to this exact request by the agent when it accepts the
|
|
2007
|
+
// turn. Receipting earlier ends the wake route on the agent side and
|
|
2008
|
+
// strips that capability from the very turn the wake raised, so the
|
|
2009
|
+
// follow-up can no longer start the next job. This waits for admission
|
|
2010
|
+
// only; the model turn itself stays detached above.
|
|
2011
|
+
if (!await admitted) {
|
|
2012
|
+
// The request may still have reached the agent, so the accepted claim
|
|
2013
|
+
// stays put: no abandon, no replay, and any retry fails closed.
|
|
1009
2014
|
return {
|
|
1010
2015
|
delivered: false,
|
|
1011
|
-
code: "
|
|
2016
|
+
code: "process_job_wake_ambiguous",
|
|
1012
2017
|
retryable: false,
|
|
1013
2018
|
ambiguous: true,
|
|
1014
2019
|
};
|
|
1015
2020
|
}
|
|
1016
|
-
this.store.completeProcessJobWake({
|
|
2021
|
+
const message = this.store.completeProcessJobWake({
|
|
1017
2022
|
sourceId: input.sourceId,
|
|
1018
2023
|
jobId: input.processJob.jobId,
|
|
1019
2024
|
deliveryKey: input.deliveryKey,
|
|
1020
2025
|
disposition: "follow_up",
|
|
1021
2026
|
turnId: started.turnId,
|
|
1022
2027
|
});
|
|
2028
|
+
if (message !== undefined) {
|
|
2029
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
2030
|
+
}
|
|
1023
2031
|
return { delivered: true, disposition: "follow_up" };
|
|
1024
2032
|
});
|
|
1025
2033
|
const tail = delivery.then(() => undefined, () => undefined);
|
|
1026
|
-
this.
|
|
1027
|
-
this.
|
|
2034
|
+
this.hostWakeTails.set(input.threadId, tail);
|
|
2035
|
+
this.activeHostWakes.set(activeKey, delivery);
|
|
1028
2036
|
try {
|
|
1029
2037
|
return await delivery;
|
|
1030
2038
|
}
|
|
1031
2039
|
finally {
|
|
1032
|
-
if (this.
|
|
1033
|
-
this.
|
|
2040
|
+
if (this.hostWakeTails.get(input.threadId) === tail) {
|
|
2041
|
+
this.hostWakeTails.delete(input.threadId);
|
|
2042
|
+
}
|
|
2043
|
+
if (this.activeHostWakes.get(activeKey) === delivery) {
|
|
2044
|
+
this.activeHostWakes.delete(activeKey);
|
|
2045
|
+
}
|
|
2046
|
+
this.releaseHostWakeReservation(input.threadId);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
async deliverMonitorWake(input) {
|
|
2050
|
+
const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
|
|
2051
|
+
const reservation = this.store.reserveMonitorWake({
|
|
2052
|
+
sourceId: input.sourceId,
|
|
2053
|
+
threadId: input.threadId,
|
|
2054
|
+
monitorId: input.monitor.monitorId,
|
|
2055
|
+
deliveryKey: input.deliveryKey,
|
|
2056
|
+
payloadSha256: monitorWakePayloadSha256(input.monitor, input.wakePrompt),
|
|
2057
|
+
monitor: input.monitor,
|
|
2058
|
+
});
|
|
2059
|
+
if (reservation.kind === "completed") {
|
|
2060
|
+
return {
|
|
2061
|
+
receipt: { delivered: true, disposition: reservation.disposition },
|
|
2062
|
+
duplicate: true,
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
if (reservation.kind === "uncertain") {
|
|
2066
|
+
const existing = this.activeHostWakes.get(activeKey);
|
|
2067
|
+
if (existing !== undefined)
|
|
2068
|
+
return { receipt: await existing, duplicate: true };
|
|
2069
|
+
return {
|
|
2070
|
+
receipt: {
|
|
2071
|
+
delivered: false,
|
|
2072
|
+
code: "monitor_wake_ambiguous",
|
|
2073
|
+
retryable: false,
|
|
2074
|
+
ambiguous: true,
|
|
2075
|
+
},
|
|
2076
|
+
duplicate: true,
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
this.retainHostWakeReservation(input.threadId);
|
|
2080
|
+
const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
|
|
2081
|
+
const delivery = previous.catch(() => undefined).then(async () => {
|
|
2082
|
+
const abandon = () => this.store.abandonMonitorWake({
|
|
2083
|
+
sourceId: input.sourceId,
|
|
2084
|
+
monitorId: input.monitor.monitorId,
|
|
2085
|
+
deliveryKey: input.deliveryKey,
|
|
2086
|
+
});
|
|
2087
|
+
let connection = this.connections.get(input.sourceId);
|
|
2088
|
+
if (connection === undefined) {
|
|
2089
|
+
try {
|
|
2090
|
+
await this.refreshAgents();
|
|
2091
|
+
}
|
|
2092
|
+
catch (error) {
|
|
2093
|
+
abandon();
|
|
2094
|
+
this.options.logger?.debug?.("Web Monitor destination refresh failed before delivery.", {
|
|
2095
|
+
threadId: input.threadId,
|
|
2096
|
+
monitorId: input.monitor.monitorId,
|
|
2097
|
+
error: errorMessage(error),
|
|
2098
|
+
});
|
|
2099
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
2100
|
+
}
|
|
2101
|
+
connection = this.connections.get(input.sourceId);
|
|
2102
|
+
}
|
|
2103
|
+
if (this.stopped || connection === undefined) {
|
|
2104
|
+
abandon();
|
|
2105
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
2106
|
+
}
|
|
2107
|
+
const destination = this.store.getThread(input.threadId);
|
|
2108
|
+
if (destination === undefined
|
|
2109
|
+
|| destination.sourceId !== input.sourceId
|
|
2110
|
+
|| destination.archivedAt !== null
|
|
2111
|
+
|| destination.trigger !== undefined) {
|
|
2112
|
+
abandon();
|
|
2113
|
+
return { delivered: false, code: "monitor_origin_mismatch", retryable: false };
|
|
2114
|
+
}
|
|
2115
|
+
const active = this.activeTurns.get(input.threadId);
|
|
2116
|
+
if (active !== undefined
|
|
2117
|
+
&& connection.info.supportsLiveInput
|
|
2118
|
+
&& input.wakePrompt.length <= AGENT_LIVE_INPUT_MAX_CHARACTERS) {
|
|
2119
|
+
try {
|
|
2120
|
+
this.store.setMonitorWakeSteeringTurn(input.sourceId, input.deliveryKey, active.turnId, true);
|
|
2121
|
+
const settlement = await active.client.liveInput({
|
|
2122
|
+
conversationId: `web:${input.threadId}`,
|
|
2123
|
+
id: input.deliveryKey,
|
|
2124
|
+
text: input.wakePrompt,
|
|
2125
|
+
receivedAt: new Date().toISOString(),
|
|
2126
|
+
deliveryKey: input.deliveryKey,
|
|
2127
|
+
signal: AbortSignal.timeout(10 * 60 * 1_000),
|
|
2128
|
+
});
|
|
2129
|
+
if (settlement.status === "applied") {
|
|
2130
|
+
const message = this.store.completeMonitorWake({
|
|
2131
|
+
sourceId: input.sourceId,
|
|
2132
|
+
monitorId: input.monitor.monitorId,
|
|
2133
|
+
deliveryKey: input.deliveryKey,
|
|
2134
|
+
disposition: "steered",
|
|
2135
|
+
turnId: active.turnId,
|
|
2136
|
+
});
|
|
2137
|
+
if (message !== undefined) {
|
|
2138
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
2139
|
+
}
|
|
2140
|
+
return { delivered: true, disposition: "steered" };
|
|
2141
|
+
}
|
|
2142
|
+
if (settlement.status !== "requeue" && settlement.status !== "unavailable") {
|
|
2143
|
+
return {
|
|
2144
|
+
delivered: false,
|
|
2145
|
+
code: "monitor_wake_ambiguous",
|
|
2146
|
+
retryable: false,
|
|
2147
|
+
ambiguous: true,
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
this.store.setMonitorWakeSteeringTurn(input.sourceId, input.deliveryKey, active.turnId, false);
|
|
2151
|
+
}
|
|
2152
|
+
catch (error) {
|
|
2153
|
+
this.options.logger?.warn?.("Web Monitor steering outcome is unknown; automatic fallback is suppressed.", {
|
|
2154
|
+
threadId: input.threadId,
|
|
2155
|
+
monitorId: input.monitor.monitorId,
|
|
2156
|
+
error: errorMessage(error),
|
|
2157
|
+
});
|
|
2158
|
+
return {
|
|
2159
|
+
delivered: false,
|
|
2160
|
+
code: "monitor_wake_ambiguous",
|
|
2161
|
+
retryable: false,
|
|
2162
|
+
ambiguous: true,
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
if (active !== undefined)
|
|
2167
|
+
await active.completion;
|
|
2168
|
+
if (this.stopped) {
|
|
2169
|
+
abandon();
|
|
2170
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
2171
|
+
}
|
|
2172
|
+
const refreshedConnection = this.connections.get(input.sourceId);
|
|
2173
|
+
if (refreshedConnection === undefined) {
|
|
2174
|
+
abandon();
|
|
2175
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
1034
2176
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
2177
|
+
let started;
|
|
2178
|
+
try {
|
|
2179
|
+
const selection = this.resolveTurnSelection(input.threadId);
|
|
2180
|
+
started = this.store.beginAssistantTurn({
|
|
2181
|
+
threadId: input.threadId,
|
|
2182
|
+
prompt: input.wakePrompt,
|
|
2183
|
+
storedPrompt: "[Monitor wake]",
|
|
2184
|
+
...(selection.model === undefined ? {} : { model: selection.model }),
|
|
2185
|
+
...(selection.effort === undefined ? {} : { effort: selection.effort }),
|
|
2186
|
+
...(selection.requestedModel === undefined ? {} : { requestedModel: selection.requestedModel }),
|
|
2187
|
+
...(selection.requestedEffort === undefined ? {} : { requestedEffort: selection.requestedEffort }),
|
|
2188
|
+
});
|
|
1037
2189
|
}
|
|
1038
|
-
|
|
2190
|
+
catch (error) {
|
|
2191
|
+
abandon();
|
|
2192
|
+
return {
|
|
2193
|
+
delivered: false,
|
|
2194
|
+
code: errorCode(error) ?? "monitor_wake_failed",
|
|
2195
|
+
retryable: false,
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
const { completion } = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
2199
|
+
this.emit("message.changed", input.threadId, {
|
|
2200
|
+
messageId: started.assistantMessageId,
|
|
2201
|
+
updatedAt: started.thread.updatedAt,
|
|
2202
|
+
});
|
|
2203
|
+
this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
|
|
2204
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
2205
|
+
await completion;
|
|
2206
|
+
if (this.store.turnStatus(started.turnId) !== "complete") {
|
|
2207
|
+
return {
|
|
2208
|
+
delivered: false,
|
|
2209
|
+
code: "monitor_wake_failed",
|
|
2210
|
+
retryable: false,
|
|
2211
|
+
ambiguous: true,
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
const message = this.store.completeMonitorWake({
|
|
2215
|
+
sourceId: input.sourceId,
|
|
2216
|
+
monitorId: input.monitor.monitorId,
|
|
2217
|
+
deliveryKey: input.deliveryKey,
|
|
2218
|
+
disposition: "follow_up",
|
|
2219
|
+
turnId: started.turnId,
|
|
2220
|
+
});
|
|
2221
|
+
if (message !== undefined) {
|
|
2222
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
2223
|
+
}
|
|
2224
|
+
return { delivered: true, disposition: "follow_up" };
|
|
2225
|
+
});
|
|
2226
|
+
const tail = delivery.then(() => undefined, () => undefined);
|
|
2227
|
+
this.hostWakeTails.set(input.threadId, tail);
|
|
2228
|
+
this.activeHostWakes.set(activeKey, delivery);
|
|
2229
|
+
try {
|
|
2230
|
+
return { receipt: await delivery, duplicate: false };
|
|
2231
|
+
}
|
|
2232
|
+
finally {
|
|
2233
|
+
if (this.hostWakeTails.get(input.threadId) === tail)
|
|
2234
|
+
this.hostWakeTails.delete(input.threadId);
|
|
2235
|
+
if (this.activeHostWakes.get(activeKey) === delivery)
|
|
2236
|
+
this.activeHostWakes.delete(activeKey);
|
|
2237
|
+
this.releaseHostWakeReservation(input.threadId);
|
|
1039
2238
|
}
|
|
1040
2239
|
}
|
|
1041
|
-
|
|
1042
|
-
this.
|
|
2240
|
+
retainHostWakeReservation(threadId) {
|
|
2241
|
+
this.hostWakeReservations.set(threadId, (this.hostWakeReservations.get(threadId) ?? 0) + 1);
|
|
1043
2242
|
}
|
|
1044
|
-
|
|
1045
|
-
const remaining = (this.
|
|
2243
|
+
releaseHostWakeReservation(threadId) {
|
|
2244
|
+
const remaining = (this.hostWakeReservations.get(threadId) ?? 1) - 1;
|
|
1046
2245
|
if (remaining > 0)
|
|
1047
|
-
this.
|
|
2246
|
+
this.hostWakeReservations.set(threadId, remaining);
|
|
1048
2247
|
else
|
|
1049
|
-
this.
|
|
2248
|
+
this.hostWakeReservations.delete(threadId);
|
|
1050
2249
|
if (!this.stopped)
|
|
1051
2250
|
void this.drainQueuedLiveInputs(threadId);
|
|
1052
2251
|
}
|
|
@@ -1069,8 +2268,19 @@ export class WebService {
|
|
|
1069
2268
|
return completed;
|
|
1070
2269
|
throw new WebConsoleError("storage_corrupt", "A new notification completed without a conversation.", 500);
|
|
1071
2270
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
2271
|
+
// Before the summaries, so a console applies the transcript move it can act
|
|
2272
|
+
// on rather than being told only that the conversation's revision moved.
|
|
2273
|
+
if (!completed.duplicate && completed.messageId !== undefined) {
|
|
2274
|
+
const written = this.store.getMessage(completed.messageId);
|
|
2275
|
+
if (written !== undefined) {
|
|
2276
|
+
this.emit("message.changed", completed.thread.id, {
|
|
2277
|
+
messageId: written.id,
|
|
2278
|
+
updatedAt: written.updatedAt,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
this.emitThread("threads.changed", { thread: completed.thread });
|
|
2283
|
+
this.emitThread("thread.changed", { thread: completed.thread });
|
|
1074
2284
|
if (!completed.duplicate) {
|
|
1075
2285
|
this.announcePushEvent(notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey));
|
|
1076
2286
|
}
|
|
@@ -1101,28 +2311,49 @@ export class WebService {
|
|
|
1101
2311
|
}
|
|
1102
2312
|
catch (error) {
|
|
1103
2313
|
this.options.logger?.warn?.("Web agent discovery failed.", { error: errorMessage(error) });
|
|
1104
|
-
const changed = this.store.
|
|
2314
|
+
const changed = this.store.markDiscoveredAgentsOffline();
|
|
1105
2315
|
this.connections = new Map();
|
|
2316
|
+
// The live connection that backs the projection is gone, so provider
|
|
2317
|
+
// authentication is unavailable now.
|
|
2318
|
+
// Clearing it here is what makes the recovery a transition worth
|
|
2319
|
+
// announcing rather than a no-op against a stale map. It is belt and
|
|
2320
|
+
// braces today: this projected capability implies a live connection, which
|
|
2321
|
+
// implies a row that was not offline, so `markDiscoveredAgentsOffline`
|
|
2322
|
+
// returns true and the failure is announced anyway.
|
|
2323
|
+
this.projectedCapabilities = new Map();
|
|
1106
2324
|
if (changed)
|
|
1107
|
-
this.emit("agents.changed"
|
|
2325
|
+
this.emit("agents.changed");
|
|
1108
2326
|
return;
|
|
1109
2327
|
}
|
|
1110
2328
|
const nextConnections = new Map();
|
|
2329
|
+
// What the cache is allowed to survive: the same process, at the same
|
|
2330
|
+
// endpoint, since the same start. Anything else is a new generation whose
|
|
2331
|
+
// catalog the previous one cannot speak for.
|
|
2332
|
+
const generations = new Map(discovered.map((agent) => [
|
|
2333
|
+
agent.source.sourceId,
|
|
2334
|
+
agentGeneration(agent),
|
|
2335
|
+
]));
|
|
2336
|
+
this.reconcileModelCatalogCache(generations);
|
|
1111
2337
|
const summaries = await Promise.all(discovered.map(async (agent) => {
|
|
2338
|
+
const generation = generations.get(agent.source.sourceId);
|
|
1112
2339
|
if (agent.baseUrl === undefined)
|
|
1113
|
-
return offlineSummary(agent);
|
|
2340
|
+
return offlineSummary(agent, generation);
|
|
1114
2341
|
const client = new OperatorClient({
|
|
1115
2342
|
baseUrl: agent.baseUrl,
|
|
1116
2343
|
...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
|
|
1117
2344
|
...(agent.processJobsBearer === undefined ? {} : { processJobsBearer: agent.processJobsBearer }),
|
|
2345
|
+
...(agent.monitorsBearer === undefined ? {} : { monitorsBearer: agent.monitorsBearer }),
|
|
1118
2346
|
...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
|
|
1119
2347
|
});
|
|
1120
2348
|
try {
|
|
1121
2349
|
const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
|
|
1122
|
-
nextConnections.set(agent.source.sourceId, { client, info });
|
|
2350
|
+
nextConnections.set(agent.source.sourceId, { client, info, generation });
|
|
2351
|
+
this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
|
|
2352
|
+
await this.restorePersistedModelAdmission(client, agent.source.sourceId, generation, info.providers, signal);
|
|
1123
2353
|
const efforts = collectEfforts(info);
|
|
1124
2354
|
return {
|
|
1125
2355
|
sourceId: agent.source.sourceId,
|
|
2356
|
+
generation,
|
|
1126
2357
|
label: info.label ?? agent.source.label,
|
|
1127
2358
|
status: agent.source.health === "running" ? "online" : "degraded",
|
|
1128
2359
|
pinned: false,
|
|
@@ -1133,6 +2364,8 @@ export class WebService {
|
|
|
1133
2364
|
...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
|
|
1134
2365
|
...(efforts.length === 0 ? {} : { efforts }),
|
|
1135
2366
|
...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
|
|
2367
|
+
runSettings: configRunSettings(info.model, info.effort),
|
|
2368
|
+
...(info.providers === undefined ? {} : { providers: info.providers }),
|
|
1136
2369
|
...(info.cron === undefined ? {} : { cron: info.cron }),
|
|
1137
2370
|
...(info.supportsAskById ? { supportsAskById: true } : {}),
|
|
1138
2371
|
updatedAt: agent.source.updatedAt,
|
|
@@ -1143,11 +2376,21 @@ export class WebService {
|
|
|
1143
2376
|
sourceId: agent.source.sourceId,
|
|
1144
2377
|
error: errorMessage(error),
|
|
1145
2378
|
});
|
|
1146
|
-
return offlineSummary(agent);
|
|
2379
|
+
return offlineSummary(agent, generation);
|
|
1147
2380
|
}
|
|
1148
2381
|
}));
|
|
1149
2382
|
this.connections = nextConnections;
|
|
1150
2383
|
const agentsChanged = this.store.replaceAgents(summaries);
|
|
2384
|
+
// Usable provider authentication comes from the live connection, so when it
|
|
2385
|
+
// turns on or off nothing on the discovery summary moves and `replaceAgents`
|
|
2386
|
+
// is right to say so. An operator can start or stop advertising
|
|
2387
|
+
// `capabilities.providerAuth` across a restart; an open console would
|
|
2388
|
+
// otherwise keep the action hidden until an unrelated change, or keep
|
|
2389
|
+
// offering one whose route now 409s. Read here after assigning the live
|
|
2390
|
+
// connections and before the cron refresh awaits on the network.
|
|
2391
|
+
const projected = new Map(summaries.map((summary) => [summary.sourceId, this.projectedCapabilitySignature(summary)]));
|
|
2392
|
+
const capabilityChanged = projected.size !== this.projectedCapabilities.size
|
|
2393
|
+
|| [...projected].some(([sourceId, signature]) => this.projectedCapabilities.get(sourceId) !== signature);
|
|
1151
2394
|
const cronChangedSources = new Set();
|
|
1152
2395
|
await Promise.all([...nextConnections.entries()].map(async ([sourceId, connection]) => {
|
|
1153
2396
|
if (connection.info.cron?.read !== true)
|
|
@@ -1165,8 +2408,14 @@ export class WebService {
|
|
|
1165
2408
|
});
|
|
1166
2409
|
}
|
|
1167
2410
|
}));
|
|
1168
|
-
|
|
1169
|
-
|
|
2411
|
+
// Adopted only once the announcement is about to go out. Assigning it
|
|
2412
|
+
// before the cron refresh would let a throw in that window consume the
|
|
2413
|
+
// transition: the next pass would compare against a map that already
|
|
2414
|
+
// carried the new signatures, find nothing changed, and the flip would be
|
|
2415
|
+
// lost rather than deferred.
|
|
2416
|
+
this.projectedCapabilities = projected;
|
|
2417
|
+
if (agentsChanged || capabilityChanged)
|
|
2418
|
+
this.emit("agents.changed");
|
|
1170
2419
|
for (const sourceId of cronChangedSources)
|
|
1171
2420
|
this.emit("cron.changed", undefined, { sourceId });
|
|
1172
2421
|
if (cronChangedSources.size > 0)
|
|
@@ -1175,6 +2424,34 @@ export class WebService {
|
|
|
1175
2424
|
void this.drainQueuedLiveInputs(threadId);
|
|
1176
2425
|
}
|
|
1177
2426
|
}
|
|
2427
|
+
requireProviderAuthConnection(sourceId) {
|
|
2428
|
+
const agent = this.store.getAgent(sourceId);
|
|
2429
|
+
if (agent === undefined)
|
|
2430
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
2431
|
+
const connection = this.connections.get(sourceId);
|
|
2432
|
+
if (connection === undefined)
|
|
2433
|
+
throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
|
|
2434
|
+
if (connection.info.supportsProviderAuth !== true) {
|
|
2435
|
+
throw new WebConsoleError("provider_auth_unavailable", "This agent does not expose provider authentication.", 409);
|
|
2436
|
+
}
|
|
2437
|
+
return connection;
|
|
2438
|
+
}
|
|
2439
|
+
async providerAuthCall(sourceId, operation) {
|
|
2440
|
+
const connection = this.requireProviderAuthConnection(sourceId);
|
|
2441
|
+
const result = await operation(connection);
|
|
2442
|
+
if (this.connections.get(sourceId)?.generation !== connection.generation) {
|
|
2443
|
+
throw new WebConsoleError("agent_generation_changed", "The agent restarted during provider authentication. Refresh status and retry.", 409);
|
|
2444
|
+
}
|
|
2445
|
+
return result;
|
|
2446
|
+
}
|
|
2447
|
+
async providerAuthCheckCall(sourceId, operation) {
|
|
2448
|
+
return await this.providerAuthCall(sourceId, async (connection) => {
|
|
2449
|
+
if (connection.info.supportsProviderAuthChecks !== true) {
|
|
2450
|
+
throw new WebConsoleError("provider_auth_unavailable", "This agent does not expose provider checks.", 409);
|
|
2451
|
+
}
|
|
2452
|
+
return await operation(connection);
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
1178
2455
|
startTimers() {
|
|
1179
2456
|
const discoveryInterval = this.options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
|
|
1180
2457
|
const purgeInterval = this.options.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
|
|
@@ -1213,6 +2490,117 @@ export class WebService {
|
|
|
1213
2490
|
this.options.logger?.info?.("Purged orphaned web uploads.", { count, partialCount, unreferencedCount });
|
|
1214
2491
|
}
|
|
1215
2492
|
}
|
|
2493
|
+
/**
|
|
2494
|
+
* A listing event that names a conversation AND describes it.
|
|
2495
|
+
*
|
|
2496
|
+
* The id comes off the payload, so the two can never disagree: an event that
|
|
2497
|
+
* named one conversation while carrying another would have every console
|
|
2498
|
+
* apply the wrong row.
|
|
2499
|
+
*/
|
|
2500
|
+
emitThread(type, payload) {
|
|
2501
|
+
this.emit(type, "thread" in payload ? payload.thread.id : payload.threadId, payload);
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Name every row a cron reconciliation actually wrote.
|
|
2505
|
+
*
|
|
2506
|
+
* A reconciliation moves a transcript and has no delta to describe it, so
|
|
2507
|
+
* each written row reaches a console as the invalidation it answers with one
|
|
2508
|
+
* message read. Rows the poll left alone are not announced: an unchanged page
|
|
2509
|
+
* must cost a console nothing.
|
|
2510
|
+
*/
|
|
2511
|
+
announceReconciledMessages(reconciled) {
|
|
2512
|
+
const written = new Set(reconciled.writtenMessageIds);
|
|
2513
|
+
for (const message of reconciled.messages) {
|
|
2514
|
+
if (!written.has(message.id))
|
|
2515
|
+
continue;
|
|
2516
|
+
this.emit("message.changed", message.threadId, {
|
|
2517
|
+
messageId: message.id,
|
|
2518
|
+
updatedAt: message.updatedAt,
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* The same as {@link emitThread}, for a write whose caller kept no snapshot of
|
|
2524
|
+
* what it produced -- a cron reconcile, a live-input hand-off -- so the store
|
|
2525
|
+
* is the only place the fresh summary can come from.
|
|
2526
|
+
*
|
|
2527
|
+
* A conversation that is no longer there leaves nothing to describe, and the
|
|
2528
|
+
* bulk form is the only honest scope left for a listing that did change.
|
|
2529
|
+
*/
|
|
2530
|
+
emitStoredThread(threadId, types) {
|
|
2531
|
+
const thread = threadId === undefined ? undefined : this.store.getThread(threadId);
|
|
2532
|
+
if (thread === undefined) {
|
|
2533
|
+
this.emit("threads.changed");
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
for (const type of types)
|
|
2537
|
+
this.emitThread(type, { thread });
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Put one persisted assistant-message write on the wire as content.
|
|
2541
|
+
*
|
|
2542
|
+
* A streaming answer is rewritten every {@link STREAM_FLUSH_INTERVAL_MS}
|
|
2543
|
+
* milliseconds. Announcing each one as an invalidation made every connected
|
|
2544
|
+
* console re-read the whole conversation to find the few characters that had
|
|
2545
|
+
* arrived, so these two paths -- the stream coalescer and the write that
|
|
2546
|
+
* settles a turn -- say what changed instead.
|
|
2547
|
+
*
|
|
2548
|
+
* Three rules keep that honest:
|
|
2549
|
+
* - A write that did not happen says nothing. `applyStreamFrames` and the
|
|
2550
|
+
* finish both answer a settled turn with no delta at all, and a version
|
|
2551
|
+
* nobody wrote is not a version to announce.
|
|
2552
|
+
* - An empty `ops` list is still announced. A status-only finish moves the
|
|
2553
|
+
* sequence number without changing a part, and a console that never heard
|
|
2554
|
+
* about it would reject the next delta as a gap.
|
|
2555
|
+
* - The parts inside a `set` are shaped exactly as a read would serve them,
|
|
2556
|
+
* and a delta that would cost more than re-reading the message declines to
|
|
2557
|
+
* the invalidation hint every other writer emits. A splice-driven finish
|
|
2558
|
+
* re-sets every part it shifted, which is bigger than the message itself.
|
|
2559
|
+
* That hint carries no sequence number, so the NEXT delta will not chain
|
|
2560
|
+
* onto the last one a console applied -- which is exactly the mismatch that
|
|
2561
|
+
* sends it to the message read.
|
|
2562
|
+
*/
|
|
2563
|
+
emitMessageWrite(threadId, write) {
|
|
2564
|
+
if (write?.attributionChanged === true) {
|
|
2565
|
+
const thread = this.store.getThread(threadId);
|
|
2566
|
+
if (thread !== undefined)
|
|
2567
|
+
this.emit("turn.changed", threadId, { turn: thread.runState });
|
|
2568
|
+
}
|
|
2569
|
+
if (write === undefined || write.delta === undefined)
|
|
2570
|
+
return;
|
|
2571
|
+
const { message, delta } = write;
|
|
2572
|
+
// An invariant, not a case: all three callers pass the assistant row of a
|
|
2573
|
+
// turn. `mapMessage` filters quote and live-input telemetry off user rows,
|
|
2574
|
+
// so a delta diffed against one would describe parts no reader ever holds,
|
|
2575
|
+
// and quietly downgrading here would hide the day that stops being true.
|
|
2576
|
+
if (message.role !== "assistant") {
|
|
2577
|
+
throw new TypeError(`A ${message.role} message reached the delta path.`);
|
|
2578
|
+
}
|
|
2579
|
+
const shaped = {
|
|
2580
|
+
...delta,
|
|
2581
|
+
// Set only once a turn, by the write that ends it. Without it a
|
|
2582
|
+
// subscribed console still bought one whole-conversation read per turn
|
|
2583
|
+
// finish, purely to draw the Activity header's window.
|
|
2584
|
+
...(message.finishedAt === undefined ? {} : { finishedAt: message.finishedAt }),
|
|
2585
|
+
ops: delta.ops.map((op) => (op.op === "set" ? { ...op, part: this.shapePart(message, op.part, {}) } : op)),
|
|
2586
|
+
};
|
|
2587
|
+
// Only a write that REWRITES parts can outweigh the message it describes: an
|
|
2588
|
+
// `append` carries strictly less than the part it grew, and the message
|
|
2589
|
+
// carries that whole part plus its own envelope. Worth the check, because
|
|
2590
|
+
// the comparison shapes the entire message -- reply capabilities re-minted
|
|
2591
|
+
// and all -- and the streaming path runs this every 50 ms.
|
|
2592
|
+
if (shaped.ops.some((op) => op.op !== "append")
|
|
2593
|
+
&& JSON.stringify(shaped.ops).length > JSON.stringify(this.shapeMessage(message)).length) {
|
|
2594
|
+
const declined = {
|
|
2595
|
+
messageId: message.id,
|
|
2596
|
+
updatedAt: message.updatedAt,
|
|
2597
|
+
deltaDeclined: true,
|
|
2598
|
+
};
|
|
2599
|
+
this.emit("message.changed", threadId, declined);
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
this.emit("message.delta", threadId, shaped);
|
|
2603
|
+
}
|
|
1216
2604
|
emit(type, threadId, payload) {
|
|
1217
2605
|
if (this.stopped)
|
|
1218
2606
|
return;
|
|
@@ -1240,7 +2628,7 @@ export class WebService {
|
|
|
1240
2628
|
}
|
|
1241
2629
|
observeAskUserFrame(threadId, turnId, frame) {
|
|
1242
2630
|
if (this.stopped || frame.kind !== "event" || frame.event.type !== "tool_call_started"
|
|
1243
|
-
||
|
|
2631
|
+
|| !isAskUserToolName(frame.event.name))
|
|
1244
2632
|
return;
|
|
1245
2633
|
const key = `${threadId}\0${turnId}`;
|
|
1246
2634
|
if (this.askWatches.has(key))
|
|
@@ -1263,8 +2651,8 @@ export class WebService {
|
|
|
1263
2651
|
const thread = this.store.applyAgentTitle(threadId, title);
|
|
1264
2652
|
if (thread === undefined)
|
|
1265
2653
|
return;
|
|
1266
|
-
this.
|
|
1267
|
-
this.
|
|
2654
|
+
this.emitThread("thread.changed", { thread });
|
|
2655
|
+
this.emitThread("threads.changed", { thread });
|
|
1268
2656
|
}
|
|
1269
2657
|
catch (error) {
|
|
1270
2658
|
this.options.logger?.warn?.("Agent conversation-title update failed; the turn is continuing.", {
|
|
@@ -1322,7 +2710,10 @@ export class WebService {
|
|
|
1322
2710
|
sourceId: thread.sourceId,
|
|
1323
2711
|
title: `${agent?.label ?? "mono-agent"} needs input`,
|
|
1324
2712
|
body: `${question.header}: ${question.question}`,
|
|
1325
|
-
|
|
2713
|
+
// Push delivery remains bounded even when the underlying AskUser wait is
|
|
2714
|
+
// unbounded. Expiring this notification does not expire the interaction.
|
|
2715
|
+
expiresAt: snapshot.expiresAt
|
|
2716
|
+
?? new Date(this.currentDate().getTime() + ASK_PUSH_DELIVERY_TTL_MS).toISOString(),
|
|
1326
2717
|
});
|
|
1327
2718
|
if (event !== undefined)
|
|
1328
2719
|
this.announcePushEvent(event.logicalKey);
|
|
@@ -1344,7 +2735,8 @@ export class WebService {
|
|
|
1344
2735
|
: await connection.client.pendingAsk(this.store.cronConversationIdForThread(event.threadId) ?? `web:${event.threadId}`, boundedSignal);
|
|
1345
2736
|
return snapshot?.interactionId === interactionId
|
|
1346
2737
|
&& snapshot.status === "pending"
|
|
1347
|
-
&&
|
|
2738
|
+
&& (snapshot.expiresAt === null
|
|
2739
|
+
|| new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime())
|
|
1348
2740
|
? "current"
|
|
1349
2741
|
: "stale";
|
|
1350
2742
|
}
|
|
@@ -1365,6 +2757,245 @@ export class WebService {
|
|
|
1365
2757
|
}
|
|
1366
2758
|
return connection;
|
|
1367
2759
|
}
|
|
2760
|
+
/**
|
|
2761
|
+
* Tier-2 admission: the catalog cache, seeded from `modelOptions` keys and
|
|
2762
|
+
* appended to by every proxied `/v1/models` page.
|
|
2763
|
+
*
|
|
2764
|
+
* Both feeds cross an await between deciding what to admit and admitting it,
|
|
2765
|
+
* so both name the generation they read. A write whose generation is no
|
|
2766
|
+
* longer the one on file is dropped outright rather than filed under the
|
|
2767
|
+
* successor: it describes a process that has been replaced, and admitting it
|
|
2768
|
+
* is exactly the cross-generation contamination `reconcileModelCatalogCache`
|
|
2769
|
+
* exists to prevent. Nothing is created here either -- an entry exists for
|
|
2770
|
+
* every discovered source from the moment a refresh reconciles it, and a
|
|
2771
|
+
* source with no entry is one discovery has dropped.
|
|
2772
|
+
*
|
|
2773
|
+
* @returns whether the refs were admitted.
|
|
2774
|
+
*/
|
|
2775
|
+
admitCatalogRefs(sourceId, generation, refs) {
|
|
2776
|
+
if (generation === undefined)
|
|
2777
|
+
return false;
|
|
2778
|
+
const entry = this.modelCatalogCache.get(sourceId);
|
|
2779
|
+
if (entry === undefined || entry.generation !== generation)
|
|
2780
|
+
return false;
|
|
2781
|
+
for (const [ref, record] of refs)
|
|
2782
|
+
this.admitModelRef(entry.models, ref, record);
|
|
2783
|
+
return true;
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Bind the cache to the agent process that filled it, and to nothing else.
|
|
2787
|
+
* A source id outlives the process behind it: reconfigure an agent and
|
|
2788
|
+
* restart it and the next generation advertises a different catalog under
|
|
2789
|
+
* the same id. Keyed by id alone, generation 1's ladder judged generation
|
|
2790
|
+
* 2's turns and rejected grades the running agent accepts, with no way to
|
|
2791
|
+
* clear it short of restarting the console.
|
|
2792
|
+
*
|
|
2793
|
+
* The single place the cache is scoped, so there is one answer to "whose
|
|
2794
|
+
* catalog is this". Runs before the per-agent probes of a refresh, so a seed
|
|
2795
|
+
* or a proxied page during that refresh files under the current generation.
|
|
2796
|
+
* Sources discovery no longer reports are dropped outright, or a retired
|
|
2797
|
+
* agent's refs would accumulate for the life of the process.
|
|
2798
|
+
*/
|
|
2799
|
+
reconcileModelCatalogCache(generations) {
|
|
2800
|
+
for (const sourceId of [...this.modelCatalogCache.keys()]) {
|
|
2801
|
+
if (!generations.has(sourceId))
|
|
2802
|
+
this.modelCatalogCache.delete(sourceId);
|
|
2803
|
+
}
|
|
2804
|
+
for (const [sourceId, generation] of generations) {
|
|
2805
|
+
if (this.modelCatalogCache.get(sourceId)?.generation === generation)
|
|
2806
|
+
continue;
|
|
2807
|
+
this.modelCatalogCache.set(sourceId, { generation, models: new Map() });
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
seedModelCatalogFromOptions(sourceId, generation, modelOptions) {
|
|
2811
|
+
if (modelOptions === undefined)
|
|
2812
|
+
return;
|
|
2813
|
+
// `modelOptions` stays the authority for the refs it names, so the seed
|
|
2814
|
+
// only records admission; recording a ladder here would shadow it.
|
|
2815
|
+
this.admitCatalogRefs(sourceId, generation, Object.keys(modelOptions).map((key) => [key, { source: "shortlist", efforts: undefined }]));
|
|
2816
|
+
}
|
|
2817
|
+
/**
|
|
2818
|
+
* A catalog-only web default outlives this process, while the admission cache
|
|
2819
|
+
* deliberately does not. Revalidate that one persisted ref against the live
|
|
2820
|
+
* generation during discovery so first-use thread creation never depends on
|
|
2821
|
+
* a browser having opened the model picker. An exact match is mandatory: a
|
|
2822
|
+
* retired ref remains unadmitted even if the search returns close names.
|
|
2823
|
+
*/
|
|
2824
|
+
async restorePersistedModelAdmission(client, sourceId, generation, providers, signal) {
|
|
2825
|
+
const model = this.store.getAgent(sourceId)?.runSettings.override?.model;
|
|
2826
|
+
if (model === undefined || this.modelCatalogCache.get(sourceId)?.models.has(model) === true)
|
|
2827
|
+
return;
|
|
2828
|
+
const separator = model.indexOf(":");
|
|
2829
|
+
// The runtime wire makes only the first colon structural: provider ids may
|
|
2830
|
+
// not contain one, while opaque model ids commonly do. Keeping that exact
|
|
2831
|
+
// split also prevents a malformed colon-bearing provider from colliding
|
|
2832
|
+
// with a legitimate provider plus colon-bearing model id.
|
|
2833
|
+
const target = separator > 0 && separator < model.length - 1
|
|
2834
|
+
? {
|
|
2835
|
+
kind: "canonical",
|
|
2836
|
+
provider: model.slice(0, separator),
|
|
2837
|
+
modelId: model.slice(separator + 1),
|
|
2838
|
+
}
|
|
2839
|
+
: { kind: "bare", modelId: model };
|
|
2840
|
+
const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]);
|
|
2841
|
+
try {
|
|
2842
|
+
if (target.kind === "canonical") {
|
|
2843
|
+
await this.restorePersistedModelFromProvider(client, sourceId, generation, target.provider, target, boundedSignal);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
// Bare ids have no provider address. Keep the cheap global search, but
|
|
2847
|
+
// accept only an exact id; if its 100-row fuzzy cap hides the target,
|
|
2848
|
+
// traverse the bounded provider window advertised in `/v1/info`.
|
|
2849
|
+
const search = await client.models({
|
|
2850
|
+
q: target.modelId,
|
|
2851
|
+
limit: MODEL_CATALOG_RESTORE_PAGE_SIZE,
|
|
2852
|
+
signal: boundedSignal,
|
|
2853
|
+
});
|
|
2854
|
+
if (this.admitModelPage(sourceId, generation, search, target))
|
|
2855
|
+
return;
|
|
2856
|
+
for (const provider of new Set((providers ?? []).map((entry) => entry.id))) {
|
|
2857
|
+
if (await this.restorePersistedModelFromProvider(client, sourceId, generation, provider, target, boundedSignal))
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
catch (error) {
|
|
2862
|
+
this.options.logger?.debug?.("Persisted web model default could not be revalidated.", {
|
|
2863
|
+
sourceId,
|
|
2864
|
+
error: errorMessage(error),
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
async restorePersistedModelFromProvider(client, sourceId, generation, provider, target, signal) {
|
|
2869
|
+
let cursor;
|
|
2870
|
+
const seenCursors = new Set();
|
|
2871
|
+
for (let pageIndex = 0; pageIndex < MODEL_CATALOG_RESTORE_PAGE_LIMIT; pageIndex += 1) {
|
|
2872
|
+
if (this.modelCatalogCache.get(sourceId)?.generation !== generation)
|
|
2873
|
+
return false;
|
|
2874
|
+
const page = await client.models({
|
|
2875
|
+
provider,
|
|
2876
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
2877
|
+
limit: MODEL_CATALOG_RESTORE_PAGE_SIZE,
|
|
2878
|
+
signal,
|
|
2879
|
+
});
|
|
2880
|
+
if (this.modelCatalogCache.get(sourceId)?.generation !== generation)
|
|
2881
|
+
return false;
|
|
2882
|
+
if (this.admitModelPage(sourceId, generation, page, target))
|
|
2883
|
+
return true;
|
|
2884
|
+
const nextCursor = page.truncated ? page.nextCursor : undefined;
|
|
2885
|
+
if (nextCursor === undefined || nextCursor.length === 0 || seenCursors.has(nextCursor))
|
|
2886
|
+
return false;
|
|
2887
|
+
seenCursors.add(nextCursor);
|
|
2888
|
+
cursor = nextCursor;
|
|
2889
|
+
}
|
|
2890
|
+
return false;
|
|
2891
|
+
}
|
|
2892
|
+
admitModelPage(sourceId, generation, page, exactTarget) {
|
|
2893
|
+
const refs = page.models.flatMap((model) => {
|
|
2894
|
+
const record = {
|
|
2895
|
+
source: "page",
|
|
2896
|
+
efforts: advertisedEffortLevels(model),
|
|
2897
|
+
advertisement: model,
|
|
2898
|
+
};
|
|
2899
|
+
// The wire carries provider-local ids while every selection surface
|
|
2900
|
+
// speaks the canonical `<provider>:<model>` reference. Admit both, or a
|
|
2901
|
+
// turn is judged against metadata the page did advertise but under a
|
|
2902
|
+
// name nothing ever asks for.
|
|
2903
|
+
const reference = model.provider ? `${model.provider}:${model.id}` : model.id;
|
|
2904
|
+
if (exactTarget?.kind === "canonical"
|
|
2905
|
+
&& (model.provider !== exactTarget.provider || model.id !== exactTarget.modelId))
|
|
2906
|
+
return [];
|
|
2907
|
+
if (exactTarget?.kind === "bare" && model.id !== exactTarget.modelId)
|
|
2908
|
+
return [];
|
|
2909
|
+
const entries = [[model.id, record]];
|
|
2910
|
+
if (reference !== model.id)
|
|
2911
|
+
entries.push([reference, record]);
|
|
2912
|
+
return entries;
|
|
2913
|
+
});
|
|
2914
|
+
return refs.length > 0 && this.admitCatalogRefs(sourceId, generation, refs);
|
|
2915
|
+
}
|
|
2916
|
+
admitModelRef(entries, ref, record) {
|
|
2917
|
+
const known = entries.get(ref);
|
|
2918
|
+
if (known !== undefined) {
|
|
2919
|
+
// A page is the live word on what it serves, so its metadata replaces
|
|
2920
|
+
// whatever is held -- including replacing a ladder with silence, which
|
|
2921
|
+
// is how a re-fetched catalog heals a model whose grades changed. The
|
|
2922
|
+
// shortlist seed only records admission and must never shadow a page.
|
|
2923
|
+
// Re-setting an existing key leaves its eviction position alone.
|
|
2924
|
+
if (record.source === "page")
|
|
2925
|
+
entries.set(ref, record);
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
entries.set(ref, record);
|
|
2929
|
+
if (entries.size > MODEL_CATALOG_CACHE_CAP) {
|
|
2930
|
+
const oldest = entries.keys().next().value;
|
|
2931
|
+
if (oldest !== undefined)
|
|
2932
|
+
entries.delete(oldest);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
/** Resolve dispatch and durable attribution from the same fresh thread snapshot. */
|
|
2936
|
+
resolveTurnSelection(threadId, explicitModel, explicitEffort) {
|
|
2937
|
+
const thread = this.store.getThread(threadId);
|
|
2938
|
+
if (thread === undefined)
|
|
2939
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
2940
|
+
const agent = this.store.getAgent(thread.sourceId);
|
|
2941
|
+
if (agent === undefined) {
|
|
2942
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
2943
|
+
}
|
|
2944
|
+
const model = explicitModel ?? thread.runModel ?? undefined;
|
|
2945
|
+
const effort = explicitEffort ?? thread.runEffort ?? undefined;
|
|
2946
|
+
this.validateModelAndEffort(thread.sourceId, agent, model, effort);
|
|
2947
|
+
const requestedModel = effectiveModelForAgent(agent, model);
|
|
2948
|
+
const cached = requestedModel === undefined
|
|
2949
|
+
? undefined
|
|
2950
|
+
: this.modelCatalogCache.get(thread.sourceId)?.models.get(requestedModel);
|
|
2951
|
+
const requestedEffort = effort ?? inheritedEffortForModel(agent, requestedModel, cached?.advertisement, agent.defaultEffort);
|
|
2952
|
+
return {
|
|
2953
|
+
thread,
|
|
2954
|
+
agent,
|
|
2955
|
+
...(model === undefined ? {} : { model }),
|
|
2956
|
+
...(effort === undefined ? {} : { effort }),
|
|
2957
|
+
...(requestedModel === undefined ? {} : { requestedModel }),
|
|
2958
|
+
...(requestedEffort === undefined ? {} : { requestedEffort }),
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
validateModelAndEffort(sourceId, agent, model, effort, strictModel = false) {
|
|
2962
|
+
if (model !== undefined && !(strictModel
|
|
2963
|
+
? this.modelStrictlyAdmitted(sourceId, agent, model)
|
|
2964
|
+
: this.modelAdmitted(sourceId, agent, model))) {
|
|
2965
|
+
throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
|
|
2966
|
+
}
|
|
2967
|
+
// Both ends resolve a blank selection to the same route -- the browser fell
|
|
2968
|
+
// back to the first shortlist entry while this stopped at `defaultModel`,
|
|
2969
|
+
// so any `/v1/info` omitting `model` had the picker offering one ladder and
|
|
2970
|
+
// this rejecting from another.
|
|
2971
|
+
const effectiveModel = effectiveModelForAgent(agent, model);
|
|
2972
|
+
// `modelOptions` only ever covers the configured shortlist, so a model
|
|
2973
|
+
// reached through the provider catalog has no entry there. `effort-ladder`
|
|
2974
|
+
// holds the tiering, and the browser runs the exact same function, so the
|
|
2975
|
+
// picker cannot offer a grade this rejects or hide one it accepts.
|
|
2976
|
+
const cached = effectiveModel === undefined
|
|
2977
|
+
? undefined
|
|
2978
|
+
: this.modelCatalogCache.get(sourceId)?.models.get(effectiveModel);
|
|
2979
|
+
const allowedEfforts = effortLevelsForModel(agent, effectiveModel, cached?.efforts);
|
|
2980
|
+
if (effort !== undefined && !allowedEfforts.includes(effort)) {
|
|
2981
|
+
throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
modelAdmitted(sourceId, agent, model) {
|
|
2985
|
+
if (this.modelStrictlyAdmitted(sourceId, agent, model))
|
|
2986
|
+
return true;
|
|
2987
|
+
// Tier 3: syntactic `<provider>:<model>` floor. Existing per-conversation
|
|
2988
|
+
// selections retain this compatibility path; durable web defaults do not.
|
|
2989
|
+
return modelPassesSyntacticFloor(model);
|
|
2990
|
+
}
|
|
2991
|
+
modelStrictlyAdmitted(sourceId, agent, model) {
|
|
2992
|
+
// Tier 1: the configured-route shortlist — unchanged, always allowed.
|
|
2993
|
+
if (agent.models === undefined ? model === agent.defaultModel : agent.models.includes(model))
|
|
2994
|
+
return true;
|
|
2995
|
+
// Tier 2: a model reached only through the catalog cache.
|
|
2996
|
+
const cached = this.modelCatalogCache.get(sourceId);
|
|
2997
|
+
return cached !== undefined && cached.models.has(model);
|
|
2998
|
+
}
|
|
1368
2999
|
authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
|
|
1369
3000
|
const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
|
|
1370
3001
|
if (access === "invalid") {
|
|
@@ -1375,7 +3006,11 @@ export class WebService {
|
|
|
1375
3006
|
if (access === "expired") {
|
|
1376
3007
|
throw new WebConsoleError("reply_access_expired", "Reply access expired. Refresh this reply part and try again.", 410);
|
|
1377
3008
|
}
|
|
1378
|
-
|
|
3009
|
+
// Answered here rather than at the route, because the SERVICE owns the
|
|
3010
|
+
// clock this expiry was signed against -- a route reading `Date.now()`
|
|
3011
|
+
// against an injected clock reports a body that may already be cold.
|
|
3012
|
+
const remainingMs = Number(expires) * 1_000 - this.currentDate().getTime();
|
|
3013
|
+
return { thread, part, remainingSeconds: Math.max(0, Math.floor(remainingMs / 1_000)) };
|
|
1379
3014
|
}
|
|
1380
3015
|
requireReplyPart(threadId, messageId, partId, type) {
|
|
1381
3016
|
const thread = this.store.getThread(threadId);
|
|
@@ -1396,20 +3031,79 @@ export class WebService {
|
|
|
1396
3031
|
throw new WebConsoleError("reply_part_expired", "The reply part has expired.", 410);
|
|
1397
3032
|
}
|
|
1398
3033
|
}
|
|
1399
|
-
decorateThreadDetail(detail) {
|
|
3034
|
+
decorateThreadDetail(detail, options = {}) {
|
|
1400
3035
|
// Backfill: messages that predate this feature, and any turn whose own
|
|
1401
3036
|
// attempt failed or was interrupted. Idempotent and guarded, so repeated
|
|
1402
3037
|
// reads of the same thread fetch each image at most once.
|
|
1403
3038
|
void this.persistReplyImages(detail.thread.id, detail.messages);
|
|
1404
|
-
return { ...detail, messages: detail.messages
|
|
3039
|
+
return { ...detail, messages: this.shapeMessages(detail.messages, options) };
|
|
1405
3040
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
3041
|
+
/**
|
|
3042
|
+
* Fold the host's provenance row into the imported-context card.
|
|
3043
|
+
*
|
|
3044
|
+
* The exact host-owned string is enough to identify this row: no model turn
|
|
3045
|
+
* can own it, and the store writes it only for canonical context imports.
|
|
3046
|
+
* Filtering independently of its neighbour is intentional, because the two
|
|
3047
|
+
* stored rows can straddle a message-page boundary. `?full=1` remains the raw
|
|
3048
|
+
* transcript escape hatch and therefore returns both stored rows unchanged.
|
|
3049
|
+
*/
|
|
3050
|
+
shapeMessages(messages, options = {}) {
|
|
3051
|
+
const visible = options.full === true
|
|
3052
|
+
? messages
|
|
3053
|
+
: messages.filter((message) => !this.isCronReplyProvenanceMessage(message));
|
|
3054
|
+
return visible.map((message) => this.shapeMessage(message, options));
|
|
3055
|
+
}
|
|
3056
|
+
isCronReplyProvenanceMessage(message) {
|
|
3057
|
+
return message.role === "system"
|
|
3058
|
+
&& message.turnId === undefined
|
|
3059
|
+
&& message.parts.length === 1
|
|
3060
|
+
&& message.parts[0]?.type === "text"
|
|
3061
|
+
&& message.parts[0].text === AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE;
|
|
3062
|
+
}
|
|
3063
|
+
/**
|
|
3064
|
+
* The one boundary every browser-facing message crosses: it mints the
|
|
3065
|
+
* short-lived reply capabilities, and it puts the transcript on a diet.
|
|
3066
|
+
*
|
|
3067
|
+
* Shaping lives HERE and never in the store. The store's parts feed the
|
|
3068
|
+
* sidebar preview, the streamed-text split, and the transcript deltas that
|
|
3069
|
+
* follow this change, all of which need the payloads whole and the indexes
|
|
3070
|
+
* exactly as recorded.
|
|
3071
|
+
*/
|
|
3072
|
+
shapeMessage(message, options = {}) {
|
|
3073
|
+
if (options.full !== true && this.isCronReplyProvenanceMessage(message)) {
|
|
3074
|
+
// Individual-message recovery cannot omit its addressed row. An empty
|
|
3075
|
+
// projection keeps the raw provenance out of the UI; list reads remove
|
|
3076
|
+
// the row altogether through `shapeMessages`.
|
|
3077
|
+
return { ...message, parts: [] };
|
|
3078
|
+
}
|
|
3079
|
+
return { ...message, parts: message.parts.map((part) => this.shapePart(message, part, options)) };
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* One part, shaped exactly as a read of its message would serve it.
|
|
3083
|
+
*
|
|
3084
|
+
* A streamed delta puts individual parts on the wire, and they cross the same
|
|
3085
|
+
* boundary the transcript does. Reading the rules from one place is what keeps
|
|
3086
|
+
* a `set` op and a re-read of the same message from disagreeing about what a
|
|
3087
|
+
* tool result contains.
|
|
3088
|
+
*/
|
|
3089
|
+
shapePart(message, part, options) {
|
|
3090
|
+
if (part.type === "attachment" || part.type === "mcp_app")
|
|
1410
3091
|
return this.decorateReplyPart(message, part);
|
|
1411
|
-
|
|
1412
|
-
|
|
3092
|
+
if (options.full === true)
|
|
3093
|
+
return part;
|
|
3094
|
+
if (message.role === "assistant"
|
|
3095
|
+
&& message.turnId === undefined
|
|
3096
|
+
&& message.parts.length === 1
|
|
3097
|
+
&& part.type === "text") {
|
|
3098
|
+
return parseCronReplyContext(part.text) ?? part;
|
|
3099
|
+
}
|
|
3100
|
+
if (part.type === "telemetry")
|
|
3101
|
+
return shapeTelemetryPart(part);
|
|
3102
|
+
if (part.type === "tool-call")
|
|
3103
|
+
return shapeToolCallPart(part);
|
|
3104
|
+
if (part.type === "subagent")
|
|
3105
|
+
return shapeSubagentPart(part);
|
|
3106
|
+
return part;
|
|
1413
3107
|
}
|
|
1414
3108
|
/**
|
|
1415
3109
|
* Keeps the console's own copy of an image the agent published.
|
|
@@ -1524,7 +3218,10 @@ export class WebService {
|
|
|
1524
3218
|
const retentionDeadline = part.expiresAt === undefined
|
|
1525
3219
|
? Number.POSITIVE_INFINITY
|
|
1526
3220
|
: Date.parse(part.expiresAt);
|
|
1527
|
-
|
|
3221
|
+
// From the bucket, not from this instant. A retention deadline that binds is
|
|
3222
|
+
// already a fixed value and is left exactly as it is.
|
|
3223
|
+
const bucketStart = Math.floor(now / REPLY_ACCESS_BUCKET_MS) * REPLY_ACCESS_BUCKET_MS;
|
|
3224
|
+
const expiresAt = Math.min(bucketStart + REPLY_ACCESS_TTL_MS, retentionDeadline);
|
|
1528
3225
|
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
1529
3226
|
return part.type === "attachment" ? { ...part, ...storedUrl } : part;
|
|
1530
3227
|
}
|
|
@@ -1616,10 +3313,15 @@ function abortableDelay(delayMs, signal) {
|
|
|
1616
3313
|
});
|
|
1617
3314
|
}
|
|
1618
3315
|
function isFuturePendingAsk(snapshot, now) {
|
|
3316
|
+
if (snapshot.status !== "pending")
|
|
3317
|
+
return false;
|
|
3318
|
+
if (snapshot.expiresAt === null)
|
|
3319
|
+
return true;
|
|
1619
3320
|
const expiresAt = new Date(snapshot.expiresAt).getTime();
|
|
1620
|
-
return
|
|
3321
|
+
return Number.isFinite(expiresAt) && expiresAt > now.getTime();
|
|
1621
3322
|
}
|
|
1622
3323
|
const STREAM_FLUSH_INTERVAL_MS = 50;
|
|
3324
|
+
const ASK_PUSH_DELIVERY_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
1623
3325
|
class WebTurnCancellation extends Error {
|
|
1624
3326
|
kind;
|
|
1625
3327
|
constructor(kind, message) {
|
|
@@ -1745,17 +3447,77 @@ export class WeightedTurnBudget {
|
|
|
1745
3447
|
}
|
|
1746
3448
|
}
|
|
1747
3449
|
}
|
|
1748
|
-
|
|
3450
|
+
/**
|
|
3451
|
+
* The identity of the agent PROCESS behind a source id. `sourceId` is stable
|
|
3452
|
+
* across restarts by design, so it cannot scope anything the running process
|
|
3453
|
+
* told us: a reconfigured agent restarts at a new endpoint, with a new pid and
|
|
3454
|
+
* a new `startedAt`, and advertises a different catalog under the same id.
|
|
3455
|
+
* Deliberately excludes `updatedAt`, which every heartbeat moves.
|
|
3456
|
+
*
|
|
3457
|
+
* This is what the model catalog cache is scoped to -- and, since the browser
|
|
3458
|
+
* caches the same `/v1/models` pages and had nothing generation-shaped to
|
|
3459
|
+
* watch, what `WebAgentSummary.generation` carries to it.
|
|
3460
|
+
*
|
|
3461
|
+
* Hashed because it now goes on the wire: the raw form names the agent's
|
|
3462
|
+
* operator endpoint and pid, and the console has no reason to hand those to a
|
|
3463
|
+
* page. The token only has to be stable while one process lives and different
|
|
3464
|
+
* once it is replaced, which a digest of those three fields is.
|
|
3465
|
+
*
|
|
3466
|
+
* Length-prefixed rather than `|`-joined. A separator that can occur inside a
|
|
3467
|
+
* field is not a separator: two different accepted tuples whose parts happen to
|
|
3468
|
+
* contain the delimiter flatten to the same string and hash to the same token,
|
|
3469
|
+
* and two distinct processes sharing a generation is precisely the state the
|
|
3470
|
+
* token exists to make impossible. Nothing first-party produces such a tuple
|
|
3471
|
+
* today, which is why this is robustness rather than a live defect --- but a
|
|
3472
|
+
* digest whose only defence is what its inputs happen to look like is one
|
|
3473
|
+
* unrelated change away from being wrong.
|
|
3474
|
+
*
|
|
3475
|
+
* Hashed as UTF-16 code units for the same reason the prefix replaced the
|
|
3476
|
+
* delimiter. UTF-8 has no encoding for an unpaired surrogate, so a lone high
|
|
3477
|
+
* surrogate and a lone low surrogate both became the replacement character and
|
|
3478
|
+
* two different one-character fields -- identically length-prefixed -- hashed
|
|
3479
|
+
* alike. `utf16le` is a lossless transcription of exactly the code units the
|
|
3480
|
+
* length prefix counts, so what is hashed is what was measured.
|
|
3481
|
+
*/
|
|
3482
|
+
export function agentGeneration(agent) {
|
|
3483
|
+
const parts = [
|
|
3484
|
+
agent.baseUrl ?? "",
|
|
3485
|
+
String(agent.source.pid ?? ""),
|
|
3486
|
+
agent.source.startedAt,
|
|
3487
|
+
];
|
|
3488
|
+
return createHash("sha256")
|
|
3489
|
+
.update(parts.map((part) => `${String(part.length)}:${part}`).join(""), "utf16le")
|
|
3490
|
+
.digest("hex")
|
|
3491
|
+
.slice(0, 16);
|
|
3492
|
+
}
|
|
3493
|
+
function offlineSummary(agent, generation) {
|
|
1749
3494
|
return {
|
|
1750
3495
|
sourceId: agent.source.sourceId,
|
|
3496
|
+
generation,
|
|
1751
3497
|
label: agent.source.label,
|
|
1752
3498
|
status: "offline",
|
|
1753
3499
|
pinned: false,
|
|
1754
3500
|
health: agent.source.health,
|
|
1755
3501
|
supportsAttachments: false,
|
|
3502
|
+
runSettings: configRunSettings(),
|
|
1756
3503
|
updatedAt: agent.source.updatedAt,
|
|
1757
3504
|
};
|
|
1758
3505
|
}
|
|
3506
|
+
function configRunSettings(model, effort) {
|
|
3507
|
+
return {
|
|
3508
|
+
config: {
|
|
3509
|
+
...(model === undefined ? {} : { model }),
|
|
3510
|
+
...(effort === undefined ? {} : { effort }),
|
|
3511
|
+
},
|
|
3512
|
+
override: null,
|
|
3513
|
+
effective: {
|
|
3514
|
+
...(model === undefined ? {} : { model }),
|
|
3515
|
+
modelSource: "config",
|
|
3516
|
+
...(effort === undefined ? {} : { effort }),
|
|
3517
|
+
effortSource: "config",
|
|
3518
|
+
},
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
1759
3521
|
function collectEfforts(info) {
|
|
1760
3522
|
// Older operator schemas do not advertise per-model metadata. Match the TUI
|
|
1761
3523
|
// picker in that case: cloud/unknown models use the canonical global effort
|
|
@@ -1763,32 +3525,25 @@ function collectEfforts(info) {
|
|
|
1763
3525
|
if (info.modelOptions === undefined)
|
|
1764
3526
|
return EFFORT_LEVELS;
|
|
1765
3527
|
const models = info.models ?? (info.model === undefined ? [] : [info.model]);
|
|
1766
|
-
|
|
3528
|
+
// Same rule as every other effort decision, so the union an agent advertises
|
|
3529
|
+
// cannot disagree with what a turn on one of those models may carry.
|
|
3530
|
+
return [...new Set(models.flatMap((model) => effortLevelsForModel(info, model, undefined)))];
|
|
1767
3531
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
if (option === undefined
|
|
1784
|
-
|| option.reasoning === false
|
|
1785
|
-
|| option.reasoningMode === "none"
|
|
1786
|
-
|| option.effortLevels?.length === 0) {
|
|
1787
|
-
return [];
|
|
1788
|
-
}
|
|
1789
|
-
if (option.reasoningMode === "toggle")
|
|
1790
|
-
return ["high", "none"];
|
|
1791
|
-
return option.effortLevels ?? [];
|
|
3532
|
+
/**
|
|
3533
|
+
* Tier 3: the syntactic floor. `@mono-agent/web` may not import pi-ai, so this
|
|
3534
|
+
* cannot be authoritative -- the agent is. It exists to reject obvious garbage,
|
|
3535
|
+
* and it must not be looser than the runtime parser, or a reference the console
|
|
3536
|
+
* accepts is silently ignored and the turn runs on the default model instead.
|
|
3537
|
+
*/
|
|
3538
|
+
function modelPassesSyntacticFloor(model) {
|
|
3539
|
+
const separator = model.indexOf(":");
|
|
3540
|
+
if (separator <= 0 || separator >= model.length - 1)
|
|
3541
|
+
return false;
|
|
3542
|
+
const provider = model.slice(0, separator);
|
|
3543
|
+
const rest = model.slice(separator + 1);
|
|
3544
|
+
// Provider ids are lowercase kebab/alphanumeric; the model half may carry
|
|
3545
|
+
// further colons and slashes but must not be blank or padded.
|
|
3546
|
+
return /^[a-z0-9][a-z0-9-]*$/u.test(provider) && rest.trim() === rest && rest.trim().length > 0;
|
|
1792
3547
|
}
|
|
1793
3548
|
function normalizeFilename(value) {
|
|
1794
3549
|
const withoutPath = value.replace(/\\/gu, "/").split("/").at(-1)?.trim() ?? "";
|